-
-
오늘의 추천 책
-
- {recommendedBooks.slice(0, 4).map((book, index) => (
-
-
- setLikedBooks((prev) => ({ ...prev, [book.id]: liked }))
- }
- />
-
- ))}
+ {/* 실시간 검색 결과 리스트 */}
+ {searchValue.trim() !== "" && (
+
+
+ {isSearching ? (
+
검색 중...
+ ) : booksToDisplay.length > 0 ? (
+
+ {booksToDisplay.map((book: Book) => (
+
{
+ router.push(`/books/${book.isbn}`);
+ onClose();
+ }}
+ className="flex items-center gap-4 p-3 hover:bg-white/10 cursor-pointer rounded-lg transition-colors"
+ >
+
+
+
+
+ {book.title}
+ {book.author}
+
+
+ ))}
+ {/* 무한 스크롤 로딩 트리거 */}
+
+ {isFetchingNextPage && (
+
+ )}
+
+
+ ) : (
+ debouncedSearchValue &&
검색 결과가 없습니다.
+ )}
+
-
-
-
-
알라딘 랭킹 더 보러가기
-
-
+ )}
+
+ {/* 오늘의 추천 책 - 검색어가 없을 때만 표시 */}
+ {!searchValue.trim() && (
+ <>
+
+
+
오늘의 추천 책
+
+ {isLoadingRecommended ? (
+
추천 도서를 불러오는 중...
+ ) : recommendedBooks.length > 0 ? (
+ recommendedBooks.map((book, index) => (
+
+
+ setLikedBooks((prev) => ({ ...prev, [book.isbn]: liked }))
+ }
+ onCardClick={() => {
+ router.push(`/books/${book.isbn}`);
+ onClose();
+ }}
+ />
+
+ ))
+ ) : (
+
추천 도서가 없습니다.
+ )}
+
+
+
+
+
알라딘 랭킹 더 보러가기
+
+
+
+
+
-
-
+ >
+ )}
-
>
);
}
diff --git a/src/hooks/mutations/useMemberMutations.ts b/src/hooks/mutations/useMemberMutations.ts
new file mode 100644
index 0000000..c0a51d0
--- /dev/null
+++ b/src/hooks/mutations/useMemberMutations.ts
@@ -0,0 +1,72 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { memberService } from "@/services/memberService";
+import { authService } from "@/services/authService";
+import { useAuthStore } from "@/store/useAuthStore";
+
+interface UpdateProfilePayload {
+ description: string;
+ categories: string[];
+ profileImageFile: File | null;
+ currentProfileImageUrl: string | null;
+}
+
+export const useUpdateProfileMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (payload: UpdateProfilePayload) => {
+ let imgUrl = payload.currentProfileImageUrl || undefined;
+
+ // 1. Image Upload (If file is selected)
+ if (payload.profileImageFile) {
+ // Assume default mime if not present
+ const contentType = payload.profileImageFile.type || "image/jpeg";
+ // Get Pre-signed URL
+ const presignedRes = await authService.getPresignedUrl("PROFILE", payload.profileImageFile.name, contentType);
+
+ // Upload to S3
+ await authService.uploadToS3(presignedRes.result!.presignedUrl, payload.profileImageFile);
+
+ // Use the returned image URL for update
+ imgUrl = presignedRes.result!.imageUrl;
+ } else if (payload.currentProfileImageUrl && payload.currentProfileImageUrl.startsWith("blob:")) {
+ // In case blob URL leaked without file, though shouldn't happen, clear it
+ imgUrl = undefined;
+ }
+
+ // 2. Update Profile Information
+ await memberService.updateProfile({
+ description: payload.description,
+ categories: payload.categories,
+ imgUrl: imgUrl || "", // Backend might expect empty string for default
+ });
+ },
+ onSuccess: async () => {
+ queryClient.invalidateQueries({ queryKey: ["member", "me"] });
+ // Fetch the updated profile and sync it to the global auth store so the Header updates
+ const response = await authService.getProfile();
+ if (response.isSuccess && response.result) {
+ useAuthStore.getState().login({
+ ...response.result,
+ email: response.result.email || "",
+ });
+ }
+ },
+ onError: (error: any) => {
+ console.error("Failed to update profile:", error);
+ },
+ });
+};
+
+import { UpdatePasswordRequest } from "@/types/member";
+
+export const useUpdatePasswordMutation = () => {
+ return useMutation({
+ mutationFn: async (payload: UpdatePasswordRequest) => {
+ await memberService.updatePassword(payload);
+ },
+ onError: (error: any) => {
+ console.error("Failed to update password:", error);
+ },
+ });
+};
diff --git a/src/hooks/mutations/useNotificationMutations.ts b/src/hooks/mutations/useNotificationMutations.ts
new file mode 100644
index 0000000..13b5b32
--- /dev/null
+++ b/src/hooks/mutations/useNotificationMutations.ts
@@ -0,0 +1,33 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { notificationService } from "@/services/notificationService";
+import { NotificationSettingType } from "@/types/notification";
+import { notificationKeys } from "../queries/useNotificationQueries";
+import toast from "react-hot-toast";
+
+export const useToggleNotificationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (settingType: NotificationSettingType) => {
+ await notificationService.updateSetting(settingType);
+ },
+ onSuccess: () => {
+ // Invalidate the settings query to refetch fresh data
+ queryClient.invalidateQueries({ queryKey: notificationKeys.settings() });
+ },
+ onError: (error: any) => {
+ toast.error(error.message || "알림 설정 변경에 실패했습니다.");
+ },
+ });
+};
+
+export const useReadNotificationMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (notificationId: number) => notificationService.readNotification(notificationId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: notificationKeys.infiniteList() });
+ },
+ });
+};
diff --git a/src/hooks/mutations/useStoryMutations.ts b/src/hooks/mutations/useStoryMutations.ts
new file mode 100644
index 0000000..21fc4bf
--- /dev/null
+++ b/src/hooks/mutations/useStoryMutations.ts
@@ -0,0 +1,46 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storyService } from "@/services/storyService";
+import { CreateBookStoryRequest, storyKeys } from "@/hooks/queries/useStoryQueries";
+
+export const useCreateBookStoryMutation = () => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: CreateBookStoryRequest) => storyService.createBookStory(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.all });
+ },
+ });
+};
+
+export const useCreateCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (args: { content: string; parentCommentId?: number }) =>
+ storyService.createComment(bookStoryId, { content: args.content }, args.parentCommentId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
+
+export const useUpdateCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (args: { commentId: number; content: string }) =>
+ storyService.updateComment(bookStoryId, args.commentId, { content: args.content }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
+
+export const useDeleteCommentMutation = (bookStoryId: number) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (commentId: number) =>
+ storyService.deleteComment(bookStoryId, commentId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: storyKeys.detail(bookStoryId) });
+ },
+ });
+};
diff --git a/src/hooks/queries/useBookQueries.ts b/src/hooks/queries/useBookQueries.ts
new file mode 100644
index 0000000..5acebdd
--- /dev/null
+++ b/src/hooks/queries/useBookQueries.ts
@@ -0,0 +1,47 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { bookService } from "@/services/bookService";
+
+export const bookKeys = {
+ all: ["books"] as const,
+ search: (title: string) => [...bookKeys.all, "search", title] as const,
+ infiniteSearch: (title: string) => [...bookKeys.all, "infiniteSearch", title] as const,
+ recommend: () => [...bookKeys.all, "recommend"] as const,
+ detail: (isbn: string) => [...bookKeys.all, "detail", isbn] as const,
+};
+
+export const useBookSearchQuery = (keyword: string) => {
+ return useQuery({
+ queryKey: bookKeys.search(keyword),
+ queryFn: () => bookService.searchBooks(keyword),
+ enabled: keyword.trim().length > 0,
+ });
+};
+
+export const useInfiniteBookSearchQuery = (keyword: string) => {
+ return useInfiniteQuery({
+ queryKey: bookKeys.infiniteSearch(keyword),
+ queryFn: ({ pageParam }) => bookService.searchBooks(keyword, pageParam),
+ initialPageParam: 1,
+ enabled: keyword.trim().length > 0,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.currentPage + 1;
+ },
+ });
+};
+
+export const useRecommendedBooksQuery = () => {
+ return useQuery({
+ queryKey: bookKeys.recommend(),
+ queryFn: () => bookService.getRecommendedBooks(),
+ staleTime: 1000 * 60 * 60, // 1 hour (recommended books don't change often)
+ });
+};
+
+export const useBookDetailQuery = (isbn: string) => {
+ return useQuery({
+ queryKey: bookKeys.detail(isbn),
+ queryFn: () => bookService.getBookDetail(isbn),
+ enabled: !!isbn,
+ });
+};
diff --git a/src/hooks/queries/useClubQueries.ts b/src/hooks/queries/useClubQueries.ts
new file mode 100644
index 0000000..f2330ca
--- /dev/null
+++ b/src/hooks/queries/useClubQueries.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import { clubService } from "@/services/clubService";
+
+export const clubKeys = {
+ all: ["clubs"] as const,
+ myList: () => [...clubKeys.all, "myList"] as const,
+};
+
+export const useMyClubsQuery = () => {
+ return useQuery({
+ queryKey: clubKeys.myList(),
+ queryFn: () => clubService.getMyClubs(),
+ });
+};
diff --git a/src/hooks/queries/useMemberQueries.ts b/src/hooks/queries/useMemberQueries.ts
new file mode 100644
index 0000000..660db0c
--- /dev/null
+++ b/src/hooks/queries/useMemberQueries.ts
@@ -0,0 +1,24 @@
+import { useQuery } from "@tanstack/react-query";
+import { memberService } from "@/services/memberService";
+
+export const memberKeys = {
+ all: ["members"] as const,
+ recommended: () => [...memberKeys.all, "recommended"] as const,
+ profile: () => [...memberKeys.all, "profile"] as const,
+};
+
+export const useRecommendedMembersQuery = (enabled: boolean = true) => {
+ return useQuery({
+ queryKey: memberKeys.recommended(),
+ queryFn: () => memberService.getRecommendedMembers(),
+ enabled,
+ });
+};
+
+export const useProfileQuery = (enabled: boolean = true) => {
+ return useQuery({
+ queryKey: memberKeys.profile(),
+ queryFn: () => memberService.getProfile(),
+ enabled,
+ });
+};
diff --git a/src/hooks/queries/useNewsQueries.ts b/src/hooks/queries/useNewsQueries.ts
new file mode 100644
index 0000000..f7409ad
--- /dev/null
+++ b/src/hooks/queries/useNewsQueries.ts
@@ -0,0 +1,22 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { newsService } from "@/services/newsService";
+import { NewsListResponse } from "@/types/news";
+
+export const newsKeys = {
+ all: ["news"] as const,
+ list: () => [...newsKeys.all, "list"] as const,
+ infiniteList: () => [...newsKeys.all, "infiniteList"] as const,
+};
+
+export const useInfiniteNewsQuery = () => {
+ return useInfiniteQuery({
+ queryKey: newsKeys.infiniteList(),
+ // 임시 MOCK 데이터 함수 연결 (운영 배포 전 newsService.getNewsList 로 복구)
+ queryFn: ({ pageParam }) => newsService.getNewsList(pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ });
+};
diff --git a/src/hooks/queries/useNotificationQueries.ts b/src/hooks/queries/useNotificationQueries.ts
new file mode 100644
index 0000000..b4fa840
--- /dev/null
+++ b/src/hooks/queries/useNotificationQueries.ts
@@ -0,0 +1,27 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { notificationService } from "@/services/notificationService";
+
+export const notificationKeys = {
+ all: ["notifications"] as const,
+ settings: () => [...notificationKeys.all, "settings"] as const,
+ infiniteList: () => [...notificationKeys.all, "infiniteList"] as const,
+};
+
+export const useNotificationSettingsQuery = () => {
+ return useQuery({
+ queryKey: notificationKeys.settings(),
+ queryFn: () => notificationService.getSettings(),
+ });
+};
+
+export const useInfiniteNotificationsQuery = () => {
+ return useInfiniteQuery({
+ queryKey: notificationKeys.infiniteList(),
+ queryFn: ({ pageParam }) => notificationService.getNotifications(pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ });
+};
diff --git a/src/hooks/queries/useStoryQueries.ts b/src/hooks/queries/useStoryQueries.ts
new file mode 100644
index 0000000..e68cbeb
--- /dev/null
+++ b/src/hooks/queries/useStoryQueries.ts
@@ -0,0 +1,50 @@
+import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
+import { storyService } from "@/services/storyService";
+export type { CreateBookStoryRequest } from "@/types/story";
+
+export const storyKeys = {
+ all: ["stories"] as const,
+ list: () => [...storyKeys.all, "list"] as const,
+ infiniteList: () => [...storyKeys.all, "infiniteList"] as const,
+ myList: () => [...storyKeys.all, "myList"] as const,
+ detail: (id: number) => [...storyKeys.all, "detail", id] as const,
+};
+
+export const useStoriesQuery = () => {
+ return useQuery({
+ queryKey: storyKeys.list(),
+ queryFn: () => storyService.getAllStories(),
+ });
+};
+
+export const useStoryDetailQuery = (id: number) => {
+ return useQuery({
+ queryKey: storyKeys.detail(id),
+ queryFn: () => storyService.getStoryById(id),
+ enabled: !!id,
+ });
+};
+
+export const useInfiniteStoriesQuery = () => {
+ return useInfiniteQuery({
+ queryKey: storyKeys.infiniteList(),
+ queryFn: ({ pageParam }) => storyService.getAllStories(pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ });
+};
+
+export const useMyInfiniteStoriesQuery = () => {
+ return useInfiniteQuery({
+ queryKey: storyKeys.myList(),
+ queryFn: ({ pageParam }) => storyService.getMyStories(pageParam ?? undefined),
+ initialPageParam: null as number | null,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || !lastPage.hasNext) return undefined;
+ return lastPage.nextCursor;
+ },
+ });
+};
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts
new file mode 100644
index 0000000..596df10
--- /dev/null
+++ b/src/hooks/useDebounce.ts
@@ -0,0 +1,19 @@
+"use client";
+
+import { useState, useEffect } from "react";
+
+export function useDebounce
(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts
index 7b987f8..281d1a5 100644
--- a/src/lib/api/client.ts
+++ b/src/lib/api/client.ts
@@ -100,6 +100,8 @@ export const apiClient = {
request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }),
put: (url: string, body?: any, options?: RequestOptions) =>
request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }),
+ patch: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "PATCH", body: body ? JSON.stringify(body) : undefined }),
delete: (url: string, options?: RequestOptions) =>
request(url, { ...options, method: "DELETE" }),
};
diff --git a/src/lib/api/client/index.ts b/src/lib/api/client/index.ts
new file mode 100644
index 0000000..b496695
--- /dev/null
+++ b/src/lib/api/client/index.ts
@@ -0,0 +1,105 @@
+"use client";
+
+import { useAuthStore } from "@/store/useAuthStore";
+import toast from "react-hot-toast";
+import { getErrorMessage, ApiError } from "../errors";
+
+interface RequestOptions extends RequestInit {
+ headers?: Record;
+ params?: Record;
+ timeout?: number; // Timeout in ms (default: 10000)
+}
+
+async function request(
+ url: string,
+ options: RequestOptions = {}
+): Promise {
+ const { params, timeout = 10000, ...fetchOptions } = options;
+
+ const defaultHeaders: Record = {
+ "Content-Type": "application/json",
+ };
+
+ // [Utility] Query String Builder
+ let requestUrl = url;
+ if (params) {
+ const searchParams = new URLSearchParams();
+ Object.entries(params).forEach(([key, value]) => {
+ if (value !== undefined && value !== null) {
+ searchParams.append(key, String(value));
+ }
+ });
+ requestUrl += `?${searchParams.toString()}`;
+ }
+
+ // [Resilience] Timeout Controller
+ const controller = new AbortController();
+ const id = setTimeout(() => controller.abort(), timeout);
+
+ const config: RequestInit = {
+ ...fetchOptions,
+ // [Security] Include credentials (cookies) for all requests
+ credentials: "include",
+ headers: {
+ ...defaultHeaders,
+ ...options.headers,
+ },
+ signal: controller.signal,
+ };
+
+ try {
+ const response = await fetch(requestUrl, config);
+ clearTimeout(id);
+
+ // [Resilience] Interceptor: 401 Unauthorized Handling
+ if (response.status === 401) {
+ console.warn("Session expired. Logging out...");
+ useAuthStore.getState().logout();
+ toast.error("세션이 만료되었습니다. 다시 로그인해주세요.");
+ }
+
+ // [Resilience] Safe JSON Parsing
+ let data: any;
+ const contentType = response.headers.get("content-type");
+ if (contentType && contentType.includes("application/json")) {
+ data = await response.json();
+ } else {
+ data = {
+ isSuccess: false,
+ message: "서버 응답 형식이 올바르지 않습니다.",
+ };
+ }
+
+ // [Standardization] Response Normalization
+ if (!response.ok || (data && data.isSuccess === false)) {
+ const errorCode = data?.code || `HTTP${response.status}`;
+ const errorMessage =
+ data?.message ||
+ getErrorMessage(errorCode) ||
+ "요청 처리 중 오류가 발생했습니다.";
+
+ throw new ApiError(errorMessage, errorCode, data);
+ }
+
+ return data;
+ } catch (error) {
+ clearTimeout(id);
+ console.error("API Request Error:", error);
+ if (error instanceof DOMException && error.name === "AbortError") {
+ toast.error("요청 시간이 초과되었습니다.");
+ throw new Error("Request timeout");
+ }
+ throw error;
+ }
+}
+
+export const apiClient = {
+ get: (url: string, options?: RequestOptions) =>
+ request(url, { ...options, method: "GET" }),
+ post: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "POST", body: body ? JSON.stringify(body) : undefined }),
+ put: (url: string, body?: any, options?: RequestOptions) =>
+ request(url, { ...options, method: "PUT", body: body ? JSON.stringify(body) : undefined }),
+ delete: (url: string, options?: RequestOptions) =>
+ request(url, { ...options, method: "DELETE" }),
+};
diff --git a/src/lib/api/endpoints/auth.ts b/src/lib/api/endpoints/auth.ts
new file mode 100644
index 0000000..93422a5
--- /dev/null
+++ b/src/lib/api/endpoints/auth.ts
@@ -0,0 +1,12 @@
+import { API_BASE_URL } from "./base";
+
+export const AUTH_ENDPOINTS = {
+ LOGIN: `${API_BASE_URL}/auth/login`,
+ SIGNUP: `${API_BASE_URL}/auth/signup`,
+ EMAIL_VERIFICATION: `${API_BASE_URL}/auth/email-verification`,
+ EMAIL_CONFIRM: `${API_BASE_URL}/auth/email-verification/confirm`,
+ LOGOUT: `${API_BASE_URL}/auth/logout`,
+ ADDITIONAL_INFO: `${API_BASE_URL}/members/additional-info`,
+ CHECK_NICKNAME: `${API_BASE_URL}/members/check-nickname`,
+ PROFILE: `${API_BASE_URL}/members/me`,
+};
diff --git a/src/lib/api/endpoints/base.ts b/src/lib/api/endpoints/base.ts
new file mode 100644
index 0000000..63241dc
--- /dev/null
+++ b/src/lib/api/endpoints/base.ts
@@ -0,0 +1,7 @@
+export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
+
+if (!API_BASE_URL) {
+ console.warn(
+ "Warning: NEXT_PUBLIC_API_URL is not defined in environment variables."
+ );
+}
diff --git a/src/lib/api/endpoints/book.ts b/src/lib/api/endpoints/book.ts
new file mode 100644
index 0000000..a35809c
--- /dev/null
+++ b/src/lib/api/endpoints/book.ts
@@ -0,0 +1,7 @@
+import { API_BASE_URL } from "./base";
+
+export const BOOK_ENDPOINTS = {
+ SEARCH: `${API_BASE_URL}/books/search`,
+ RECOMMEND: `${API_BASE_URL}/books/recommend`,
+ DETAIL: (isbn: string) => `${API_BASE_URL}/books/${isbn}`,
+};
diff --git a/src/lib/api/endpoints/bookstory.ts b/src/lib/api/endpoints/bookstory.ts
new file mode 100644
index 0000000..a88bb46
--- /dev/null
+++ b/src/lib/api/endpoints/bookstory.ts
@@ -0,0 +1,6 @@
+import { API_BASE_URL } from "./base";
+
+export const STORY_ENDPOINTS = {
+ LIST: `${API_BASE_URL}/book-stories`,
+ ME: `${API_BASE_URL}/book-stories/me`,
+};
diff --git a/src/lib/api/endpoints/club.ts b/src/lib/api/endpoints/club.ts
new file mode 100644
index 0000000..ae15a93
--- /dev/null
+++ b/src/lib/api/endpoints/club.ts
@@ -0,0 +1,5 @@
+import { API_BASE_URL } from "./base";
+
+export const CLUB_ENDPOINTS = {
+ MY_CLUBS: `${API_BASE_URL}/me/clubs`,
+};
diff --git a/src/lib/api/endpoints/index.ts b/src/lib/api/endpoints/index.ts
new file mode 100644
index 0000000..463a8a2
--- /dev/null
+++ b/src/lib/api/endpoints/index.ts
@@ -0,0 +1,5 @@
+export * from "./base";
+export * from "./auth";
+export * from "./bookstory";
+export * from "./member";
+export * from "./book";
diff --git a/src/lib/api/endpoints/member.ts b/src/lib/api/endpoints/member.ts
new file mode 100644
index 0000000..bdc973c
--- /dev/null
+++ b/src/lib/api/endpoints/member.ts
@@ -0,0 +1,8 @@
+import { API_BASE_URL } from "./base";
+
+export const MEMBER_ENDPOINTS = {
+ GET_PROFILE: `${API_BASE_URL}/members/me`,
+ RECOMMEND: `${API_BASE_URL}/members/me/recommend`,
+ UPDATE_PROFILE: `${API_BASE_URL}/members/me`,
+ UPDATE_PASSWORD: `${API_BASE_URL}/members/me/update-password`,
+};
diff --git a/src/lib/api/endpoints/news.ts b/src/lib/api/endpoints/news.ts
new file mode 100644
index 0000000..acf55c1
--- /dev/null
+++ b/src/lib/api/endpoints/news.ts
@@ -0,0 +1,5 @@
+import { API_BASE_URL } from "./base";
+
+export const NEWS_ENDPOINTS = {
+ GET_NEWS_LIST: `${API_BASE_URL}/news`,
+};
diff --git a/src/lib/api/endpoints/notification.ts b/src/lib/api/endpoints/notification.ts
new file mode 100644
index 0000000..872b0d5
--- /dev/null
+++ b/src/lib/api/endpoints/notification.ts
@@ -0,0 +1,8 @@
+import { API_BASE_URL } from "./base";
+
+export const NOTIFICATION_ENDPOINTS = {
+ GET_SETTINGS: `${API_BASE_URL}/notifications/settings`,
+ UPDATE_SETTING: (settingType: string) => `${API_BASE_URL}/notifications/settings/${settingType}`,
+ GET_NOTIFICATIONS: `${API_BASE_URL}/notifications`,
+ READ_NOTIFICATION: (id: number) => `${API_BASE_URL}/notifications/${id}/read`,
+};
diff --git a/src/lib/api/errors/ApiError.ts b/src/lib/api/errors/ApiError.ts
new file mode 100644
index 0000000..3f3fd12
--- /dev/null
+++ b/src/lib/api/errors/ApiError.ts
@@ -0,0 +1,11 @@
+export class ApiError extends Error {
+ code: string;
+ response?: any;
+
+ constructor(message: string, code: string = "UNKNOWN_ERROR", response?: any) {
+ super(message);
+ this.name = "ApiError";
+ this.code = code;
+ this.response = response;
+ }
+}
diff --git a/src/lib/api/errors/errorMapper.ts b/src/lib/api/errors/errorMapper.ts
new file mode 100644
index 0000000..3b79c67
--- /dev/null
+++ b/src/lib/api/errors/errorMapper.ts
@@ -0,0 +1,19 @@
+export const ERROR_MESSAGES: Record = {
+ // Common Errors
+ COMMON400: "잘못된 요청입니다.",
+ COMMON401: "인증이 필요합니다.",
+ COMMON403: "접근 권한이 없습니다.",
+ COMMON404: "요청한 리소스를 찾을 수 없습니다.",
+ COMMON500: "서버 내부 오류가 발생했습니다.",
+
+ // Auth Errors
+ USER_NOT_FOUND: "존재하지 않는 사용자입니다.",
+ WRONG_PASSWORD: "비밀번호가 일치하지 않습니다.",
+ DUPLICATE_EMAIL: "이미 사용 중인 이메일입니다.",
+ INVALID_TOKEN: "유효하지 않은 토큰입니다.",
+ EXPIRED_TOKEN: "만료된 토큰입니다.",
+};
+
+export function getErrorMessage(code: string): string {
+ return ERROR_MESSAGES[code] || "알 수 없는 오류가 발생했습니다.";
+}
diff --git a/src/lib/api/errors/index.ts b/src/lib/api/errors/index.ts
new file mode 100644
index 0000000..a184b37
--- /dev/null
+++ b/src/lib/api/errors/index.ts
@@ -0,0 +1,2 @@
+export * from "./ApiError";
+export * from "./errorMapper";
diff --git a/src/services/bookService.ts b/src/services/bookService.ts
new file mode 100644
index 0000000..c30481d
--- /dev/null
+++ b/src/services/bookService.ts
@@ -0,0 +1,31 @@
+import { apiClient } from "@/lib/api/client";
+import { BOOK_ENDPOINTS } from "@/lib/api/endpoints/book";
+import { ApiResponse } from "@/types/auth";
+import { Book, BookSearchResponse } from "@/types/book";
+
+export const bookService = {
+ searchBooks: async (keyword: string, page: number = 1): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.SEARCH,
+ {
+ params: {
+ keyword,
+ page
+ },
+ }
+ );
+ return response.result!;
+ },
+ getRecommendedBooks: async (): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.RECOMMEND
+ );
+ return response.result!;
+ },
+ getBookDetail: async (isbn: string): Promise => {
+ const response = await apiClient.get>(
+ BOOK_ENDPOINTS.DETAIL(isbn)
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/clubService.ts b/src/services/clubService.ts
new file mode 100644
index 0000000..592fbdf
--- /dev/null
+++ b/src/services/clubService.ts
@@ -0,0 +1,13 @@
+import { apiClient } from "@/lib/api/client";
+import { CLUB_ENDPOINTS } from "@/lib/api/endpoints/club";
+import { MyClubListResponse } from "@/types/club";
+import { ApiResponse } from "@/types/auth";
+
+export const clubService = {
+ getMyClubs: async (): Promise => {
+ const response = await apiClient.get>(
+ CLUB_ENDPOINTS.MY_CLUBS
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/memberService.ts b/src/services/memberService.ts
new file mode 100644
index 0000000..4bd95be
--- /dev/null
+++ b/src/services/memberService.ts
@@ -0,0 +1,37 @@
+import { apiClient } from "@/lib/api/client";
+import { MEMBER_ENDPOINTS } from "@/lib/api/endpoints/member";
+import { RecommendResponse, UpdateProfileRequest, UpdatePasswordRequest, ProfileResponse } from "@/types/member";
+import { ApiResponse } from "@/types/auth";
+
+export const memberService = {
+ getRecommendedMembers: async (): Promise => {
+ const response = await apiClient.get>(
+ MEMBER_ENDPOINTS.RECOMMEND
+ );
+ return response.result!;
+ },
+ updateProfile: async (data: UpdateProfileRequest): Promise => {
+ const response = await apiClient.patch>(
+ MEMBER_ENDPOINTS.UPDATE_PROFILE,
+ data
+ );
+ if (!response.isSuccess) {
+ throw new Error(response.message || "Failed to update profile");
+ }
+ },
+ updatePassword: async (data: UpdatePasswordRequest): Promise => {
+ const response = await apiClient.patch>(
+ MEMBER_ENDPOINTS.UPDATE_PASSWORD,
+ data
+ );
+ if (!response.isSuccess) {
+ throw new Error(response.message || "Failed to update password");
+ }
+ },
+ getProfile: async (): Promise => {
+ const response = await apiClient.get>(
+ MEMBER_ENDPOINTS.GET_PROFILE
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/newsService.ts b/src/services/newsService.ts
new file mode 100644
index 0000000..3140a3f
--- /dev/null
+++ b/src/services/newsService.ts
@@ -0,0 +1,18 @@
+import { apiClient } from "@/lib/api/client";
+import { NEWS_ENDPOINTS } from "@/lib/api/endpoints/news";
+import { NewsListResponse } from "@/types/news";
+import { ApiResponse } from "@/types/auth";
+
+export const newsService = {
+ getNewsList: async (cursorId?: number): Promise => {
+ const url = new URL(NEWS_ENDPOINTS.GET_NEWS_LIST);
+ if (cursorId) {
+ url.searchParams.append("cursorId", cursorId.toString());
+ }
+
+ const response = await apiClient.get>(
+ url.toString()
+ );
+ return response.result!;
+ },
+};
diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts
new file mode 100644
index 0000000..f819e21
--- /dev/null
+++ b/src/services/notificationService.ts
@@ -0,0 +1,40 @@
+import { apiClient } from "@/lib/api/client";
+import { NOTIFICATION_ENDPOINTS } from "@/lib/api/endpoints/notification";
+import { NotificationSettings, NotificationSettingType, NotificationListResponse } from "@/types/notification";
+import { ApiResponse } from "@/types/auth";
+
+export const notificationService = {
+ getSettings: async (): Promise => {
+ const response = await apiClient.get>(
+ NOTIFICATION_ENDPOINTS.GET_SETTINGS
+ );
+ return response.result!;
+ },
+ updateSetting: async (settingType: NotificationSettingType): Promise => {
+ const response = await apiClient.patch>(
+ NOTIFICATION_ENDPOINTS.UPDATE_SETTING(settingType)
+ );
+ if (!response.isSuccess) {
+ throw new Error(response.message || "Failed to update notification setting");
+ }
+ },
+ getNotifications: async (cursorId?: number): Promise => {
+ const url = new URL(NOTIFICATION_ENDPOINTS.GET_NOTIFICATIONS);
+ if (cursorId) {
+ url.searchParams.append("cursorId", cursorId.toString());
+ }
+
+ const response = await apiClient.get>(
+ url.toString()
+ );
+ return response.result!;
+ },
+ readNotification: async (notificationId: number): Promise => {
+ const response = await apiClient.patch>(
+ NOTIFICATION_ENDPOINTS.READ_NOTIFICATION(notificationId)
+ );
+ if (!response.isSuccess) {
+ throw new Error(response.message || "Failed to mark notification as read");
+ }
+ },
+};
diff --git a/src/services/storyService.ts b/src/services/storyService.ts
new file mode 100644
index 0000000..b0e7d35
--- /dev/null
+++ b/src/services/storyService.ts
@@ -0,0 +1,72 @@
+import { apiClient } from "@/lib/api/client";
+import { STORY_ENDPOINTS } from "@/lib/api/endpoints/bookstory";
+import { BookStoryListResponse, BookStoryDetail, CreateBookStoryRequest } from "@/types/story";
+import { ApiResponse } from "@/types/auth";
+
+export const storyService = {
+ getAllStories: async (cursorId?: number): Promise => {
+ const response = await apiClient.get>(
+ STORY_ENDPOINTS.LIST,
+ {
+ params: { cursorId },
+ }
+ );
+ return response.result!;
+ },
+ getMyStories: async (cursorId?: number): Promise => {
+ const response = await apiClient.get>(
+ STORY_ENDPOINTS.ME,
+ {
+ params: { cursorId },
+ }
+ );
+ return response.result!;
+ },
+ getStoryById: async (id: number): Promise => {
+ const response = await apiClient.get>(
+ `${STORY_ENDPOINTS.LIST}/${id}`
+ );
+ return response.result!;
+ },
+ createBookStory: async (data: CreateBookStoryRequest): Promise => {
+ const response = await apiClient.post>(
+ STORY_ENDPOINTS.LIST,
+ data
+ );
+ return response.result!;
+ },
+ createComment: async (
+ bookStoryId: number,
+ data: { content: string },
+ parentCommentId?: number
+ ): Promise => {
+ const response = await apiClient.post>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments`,
+ data,
+ {
+ params: { parentCommentId }
+ }
+ );
+ return response.result!;
+ },
+ updateComment: async (
+ bookStoryId: number,
+ commentId: number,
+ data: { content: string }
+ ): Promise => {
+ const response = await apiClient.patch>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}`,
+ data
+ );
+ return response.result!;
+ },
+ deleteComment: async (
+ bookStoryId: number,
+ commentId: number
+ ): Promise => {
+ const response = await apiClient.delete>(
+ `${STORY_ENDPOINTS.LIST}/${bookStoryId}/comments/${commentId}`
+ );
+ return response.result!;
+ },
+};
diff --git a/src/types/book.ts b/src/types/book.ts
new file mode 100644
index 0000000..11b992f
--- /dev/null
+++ b/src/types/book.ts
@@ -0,0 +1,15 @@
+export interface Book {
+ isbn: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+ publisher: string;
+ description: string;
+ link: string;
+}
+
+export interface BookSearchResponse {
+ detailInfoList: Book[];
+ hasNext: boolean;
+ currentPage: number;
+}
diff --git a/src/types/club.ts b/src/types/club.ts
new file mode 100644
index 0000000..175527d
--- /dev/null
+++ b/src/types/club.ts
@@ -0,0 +1,8 @@
+export interface MyClubInfo {
+ clubId: number;
+ clubName: string;
+}
+
+export interface MyClubListResponse {
+ clubList: MyClubInfo[];
+}
diff --git a/src/types/groups/bookcasedetail.ts b/src/types/groups/bookcasedetail.ts
new file mode 100644
index 0000000..7c75b2e
--- /dev/null
+++ b/src/types/groups/bookcasedetail.ts
@@ -0,0 +1,45 @@
+export type MemberInfo = {
+ nickname: string;
+ profileImageUrl: string | null;
+};
+
+export type TeamMember = {
+ // TODO(BE 연동): GET 응답에 clubMemberId가 포함되어야 함.
+ clubMemberId: number;
+ memberInfo: MemberInfo;
+ teamNumber: number | null;
+};
+
+export type GetMeetingTeamsResult = {
+ existingTeamNumbers: number[];
+ members: TeamMember[];
+ hasNext: boolean;
+ nextCursor: string | null;
+};
+
+export type TeamMemberListPutBody = {
+ teamMemberList: {
+ teamNumber: number;
+ clubMemberIds: number[];
+ }[];
+};
+
+export const MAX_TEAMS = 7;
+
+export function teamLabel(teamNumber: number) {
+ // 1 -> A, 2 -> B ...
+ const code = 64 + teamNumber;
+ if (code < 65 || code > 90) return `${teamNumber}`;
+ return String.fromCharCode(code);
+}
+
+export function normalizeTeams(input: number[]) {
+ // 팀 번호가 이상하게 들어와도 1..N 형태로 정규화
+ const uniqueSorted = Array.from(new Set(input))
+ .filter((n) => Number.isFinite(n) && n > 0)
+ .sort((a, b) => a - b);
+
+ if (uniqueSorted.length === 0) return [1];
+ // 1..N이 아닐 수도 있으니 갯수만큼 1..len
+ return uniqueSorted.map((_, idx) => idx + 1);
+}
diff --git a/src/types/groups/bookcasehome.ts b/src/types/groups/bookcasehome.ts
new file mode 100644
index 0000000..e24ddd1
--- /dev/null
+++ b/src/types/groups/bookcasehome.ts
@@ -0,0 +1,93 @@
+// src/types/bookcase.ts
+const DEFAULT_BOOK_COVER = "/dummy_book_cover.png";
+/** ===== API Raw Types ===== */
+export type BookcaseApiResponse = {
+ isSuccess: boolean;
+ code: string;
+ message: string;
+ result: BookcaseApiResult;
+};
+
+export type BookcaseApiResult = {
+ bookShelfInfoList: BookShelfInfo[];
+ hasNext: boolean;
+ nextCursor: string | null;
+};
+
+export type BookShelfInfo = {
+ meetingInfo: MeetingInfo;
+ bookInfo: BookInfo;
+};
+
+export type MeetingInfo = {
+ meetingId: number;
+ generation: number; // 1,2,3...
+ tag: "MEETING" | string; // 서버가 더 늘리면 string으로 대응
+ averageRate: number; // 평점(0~5 같은 값일 가능성)
+};
+
+export type BookInfo = {
+ bookId: string; // ISBN 같은 문자열
+ title: string;
+ author: string;
+ imgUrl: string | null;
+};
+
+/** ===== UI View Model Types (BookcaseCard에 꽂기 좋은 형태) ===== */
+export type BookcaseCardCategory = {
+ generation: string; // "1기" 같은 라벨
+ genre: string; // 서버에 장르 없으니 tag 등으로 대체
+};
+
+export type BookcaseCardModel = {
+ bookId: string;
+ title: string;
+ author: string;
+ imageUrl: string;
+ category: BookcaseCardCategory;
+ rating: number;
+ meetingId: number;
+ generationNumber: number;
+ tag: string;
+};
+
+export type BookcaseSectionModel = {
+ generationNumber: number;
+ generationLabel: string; // "1기"
+ books: BookcaseCardModel[];
+};
+
+/** ===== Mapper / Grouping Utils ===== */
+export const toBookcaseCardModel = (item: BookShelfInfo): BookcaseCardModel => ({
+ bookId: item.bookInfo.bookId,
+ title: item.bookInfo.title,
+ author: item.bookInfo.author,
+ imageUrl: item.bookInfo.imgUrl ?? DEFAULT_BOOK_COVER,
+ category: {
+ generation: `${item.meetingInfo.generation}기`,
+ genre: item.meetingInfo.tag, // 서버에 genre 없으니 tag를 박아둠
+ },
+ rating: item.meetingInfo.averageRate ?? 0,
+ meetingId: item.meetingInfo.meetingId,
+ generationNumber: item.meetingInfo.generation,
+ tag: item.meetingInfo.tag,
+});
+
+export const groupByGeneration = (list: BookShelfInfo[]): BookcaseSectionModel[] => {
+ const map = new Map();
+
+ for (const item of list) {
+ const gen = item.meetingInfo.generation;
+ const arr = map.get(gen) ?? [];
+ arr.push(toBookcaseCardModel(item));
+ map.set(gen, arr);
+ }
+
+ return Array.from(map.entries())
+ .sort((a, b) => b[0] - a[0]) // 최신 기수 먼저 (원하면 반대로)
+ .map(([generationNumber, books]) => ({
+ generationNumber,
+ generationLabel: `${generationNumber}기`,
+ books,
+ }));
+};
diff --git a/src/types/member.ts b/src/types/member.ts
new file mode 100644
index 0000000..f2f0043
--- /dev/null
+++ b/src/types/member.ts
@@ -0,0 +1,27 @@
+export interface RecommendedMember {
+ nickname: string;
+ profileImageUrl: string;
+}
+
+export interface RecommendResponse {
+ friends: RecommendedMember[];
+}
+
+export interface UpdateProfileRequest {
+ description: string;
+ categories: string[];
+ imgUrl?: string;
+}
+
+export interface UpdatePasswordRequest {
+ currentPassword?: string;
+ newPassword?: string;
+ confirmPassword?: string;
+}
+
+export interface ProfileResponse {
+ nickname: string;
+ description: string;
+ profileImageUrl: string;
+ categories: string[];
+}
diff --git a/src/types/news.ts b/src/types/news.ts
new file mode 100644
index 0000000..7e7d3ca
--- /dev/null
+++ b/src/types/news.ts
@@ -0,0 +1,14 @@
+export interface NewsBasicInfo {
+ newsId: number;
+ title: string;
+ description: string;
+ thumbnailUrl: string;
+ publishStartAt: string;
+}
+
+export interface NewsListResponse {
+ basicInfoList: NewsBasicInfo[];
+ hasNext: boolean;
+ nextCursor: number | null;
+ pageSize: number;
+}
diff --git a/src/types/notification.ts b/src/types/notification.ts
new file mode 100644
index 0000000..35d6edf
--- /dev/null
+++ b/src/types/notification.ts
@@ -0,0 +1,33 @@
+export type NotificationSettingType =
+ | "BOOK_STORY_LIKED"
+ | "BOOK_STORY_COMMENT"
+ | "CLUB_NOTICE_CREATED"
+ | "CLUB_MEETING_CREATED"
+ | "NEW_FOLLOWER"
+ | "JOIN_CLUB";
+
+export interface NotificationSettings {
+ bookStoryLiked: boolean;
+ bookStoryComment: boolean;
+ clubNoticeCreated: boolean;
+ clubMeetingCreated: boolean;
+ newFollower: boolean;
+ joinClub: boolean;
+}
+
+export interface NotificationBasicInfo {
+ notificationId: number;
+ notificationType: "LIKE" | "COMMENT" | "FOLLOW" | "JOIN_CLUB" | "CLUB_MEETING_CREATED" | "CLUB_NOTICE_CREATED";
+ domainId: number;
+ sourceId: number;
+ displayName: string;
+ read: boolean;
+ createdAt: string;
+}
+
+export interface NotificationListResponse {
+ notifications: NotificationBasicInfo[];
+ hasNext: boolean;
+ nextCursor: number | null;
+ pageSize: number;
+}
diff --git a/src/types/story.ts b/src/types/story.ts
new file mode 100644
index 0000000..402d779
--- /dev/null
+++ b/src/types/story.ts
@@ -0,0 +1,88 @@
+export interface BookStory {
+ bookStoryId: number;
+ bookInfo: {
+ bookId: number;
+ title: string;
+ author: string;
+ imgUrl: string;
+ };
+ authorInfo: {
+ nickname: string;
+ profileImageUrl: string;
+ following: boolean;
+ };
+ bookStoryTitle: string;
+ description: string;
+ likes: number;
+ commentCount: number;
+ viewCount: number;
+ likedByMe: boolean;
+ createdAt: string;
+ writtenByMe: boolean;
+}
+
+export interface BookStoryListResponse {
+ basicInfoList: BookStory[];
+ hasNext: boolean;
+ nextCursor: number | null;
+ pageSize: number;
+}
+
+
+export interface BookInfo {
+ bookId: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+}
+
+export interface AuthorInfo {
+ nickname: string;
+ profileImageUrl: string;
+ following: boolean;
+}
+
+export interface CommentInfo {
+ commentId: number;
+ content: string;
+ authorInfo: AuthorInfo;
+ createdAt: string;
+ writtenByMe: boolean;
+ deleted: boolean;
+ parentCommentId?: number | null;
+}
+
+
+export interface BookStoryDetail {
+ bookStoryId: number;
+ bookInfo: BookInfo;
+ authorInfo: AuthorInfo;
+ bookStoryTitle: string;
+ description: string;
+ likes: number;
+ likedByMe: boolean;
+ createdAt: string;
+ writtenByMe: boolean;
+ viewCount: number;
+ commentCount: number;
+ comments: CommentInfo[];
+ prevBookStoryId: number;
+ nextBookStoryId: number;
+}
+
+export interface CreateBookStoryRequest {
+ bookInfo: {
+ isbn: string;
+ title: string;
+ author: string;
+ imgUrl: string;
+ publisher: string;
+ description: string;
+ };
+ title: string;
+ description: string;
+}
+
+export interface CreateCommentRequest {
+ content: string;
+}
diff --git a/src/utils/time.ts b/src/utils/time.ts
new file mode 100644
index 0000000..1cd005f
--- /dev/null
+++ b/src/utils/time.ts
@@ -0,0 +1,35 @@
+export function formatTimeAgo(dateInput: Date | string): string {
+ let parsedInput = dateInput;
+ if (typeof dateInput === 'string') {
+ parsedInput = dateInput.replace(" ", "T");
+ }
+ const targetDate = new Date(parsedInput);
+ const now = new Date();
+
+ if (isNaN(targetDate.getTime())) {
+ return "";
+ }
+
+ const diffMs = now.getTime() - targetDate.getTime();
+ const diffMins = Math.floor(diffMs / (1000 * 60));
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
+
+ if (diffDays > 30) {
+ const year = targetDate.getFullYear();
+ const month = String(targetDate.getMonth() + 1).padStart(2, "0");
+ const day = String(targetDate.getDate()).padStart(2, "0");
+ return `${year}.${month}.${day}.`;
+ }
+
+ if (diffDays > 0) {
+ return `${diffDays}일 전`;
+ }
+ if (diffHours > 0) {
+ return `${diffHours}시간 전`;
+ }
+ if (diffMins > 0) {
+ return `${diffMins}분 전`;
+ }
+ return "방금 전";
+}
diff --git a/src/utils/url.ts b/src/utils/url.ts
new file mode 100644
index 0000000..b27b404
--- /dev/null
+++ b/src/utils/url.ts
@@ -0,0 +1,25 @@
+/**
+ * URL 유효성 검사 (Swagger 기본값 "string" 또는 빈 값 처리)
+ */
+export const isValidUrl = (url: string | null | undefined): boolean => {
+ if (!url || url === "string" || url.trim() === "") return false;
+
+ // 허용되는 상대 경로 패턴 (예: /profile2.svg)
+ if (url.startsWith("/")) return true;
+
+ try {
+ new URL(url);
+ return true; // http, https 등 유효한 scheme이 있는 경우
+ } catch {
+ // 프로토콜 상대 URL 지원 (예: //example.com/image.png)
+ if (url.startsWith("//")) {
+ try {
+ new URL(`https:${url}`);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ return false;
+ }
+};