=> {
+ if (!refreshAccessTokenPromise) {
+ refreshAccessTokenPromise = requestAccessTokenRefresh().finally(() => {
+ refreshAccessTokenPromise = null;
+ });
+ }
+
+ return refreshAccessTokenPromise;
+};
+
export const signup = async (data: SignupRequest) => {
const response = await apiClient.post('users/signup', {
body: data,
diff --git a/src/apis/auth/mutations.ts b/src/apis/auth/mutations.ts
new file mode 100644
index 00000000..395411fd
--- /dev/null
+++ b/src/apis/auth/mutations.ts
@@ -0,0 +1,32 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { deleteMyAccount, logout, putMyInfo, signup } from '.';
+
+export const authMutationKeys = {
+ signup: () => ['signup'] as const,
+ logout: () => ['logout'] as const,
+ withdraw: () => ['withdraw'] as const,
+ updateMyInfo: () => ['modifyMyInfo'] as const,
+};
+
+export const authMutations = {
+ signup: () =>
+ mutationOptions({
+ mutationKey: authMutationKeys.signup(),
+ mutationFn: signup,
+ }),
+ logout: () =>
+ mutationOptions({
+ mutationKey: authMutationKeys.logout(),
+ mutationFn: logout,
+ }),
+ withdraw: () =>
+ mutationOptions({
+ mutationKey: authMutationKeys.withdraw(),
+ mutationFn: deleteMyAccount,
+ }),
+ updateMyInfo: () =>
+ mutationOptions({
+ mutationKey: authMutationKeys.updateMyInfo(),
+ mutationFn: putMyInfo,
+ }),
+};
diff --git a/src/apis/auth/queries.ts b/src/apis/auth/queries.ts
new file mode 100644
index 00000000..fb0e2e10
--- /dev/null
+++ b/src/apis/auth/queries.ts
@@ -0,0 +1,27 @@
+import { queryOptions } from '@tanstack/react-query';
+import { getMyInfo, getMyOAuthLinks, getSignupPrefill } from '.';
+
+export const authQueryKeys = {
+ all: ['user'] as const,
+ myInfo: () => [...authQueryKeys.all, 'myInfo'] as const,
+ oauthLinks: () => [...authQueryKeys.all, 'oauthLinks'] as const,
+ signupPrefill: () => [...authQueryKeys.all, 'prefill'] as const,
+};
+
+export const authQueries = {
+ myInfo: () =>
+ queryOptions({
+ queryKey: authQueryKeys.myInfo(),
+ queryFn: getMyInfo,
+ }),
+ oauthLinks: () =>
+ queryOptions({
+ queryKey: authQueryKeys.oauthLinks(),
+ queryFn: getMyOAuthLinks,
+ }),
+ signupPrefill: () =>
+ queryOptions({
+ queryKey: authQueryKeys.signupPrefill(),
+ queryFn: getSignupPrefill,
+ }),
+};
diff --git a/src/apis/chat/mutations.ts b/src/apis/chat/mutations.ts
new file mode 100644
index 00000000..055b801a
--- /dev/null
+++ b/src/apis/chat/mutations.ts
@@ -0,0 +1,38 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { postAdminChatRoom, postChatMessage, postChatMute, postChatRooms } from '@/apis/chat';
+
+export const chatMutationKeys = {
+ createRoom: () => ['chat', 'createRoom'] as const,
+ createAdminRoom: () => ['chat', 'createAdminRoom'] as const,
+ sendMessage: () => ['chat', 'sendMessage'] as const,
+ toggleMute: (chatRoomId?: number) => ['chat', 'toggleMute', chatRoomId ?? 'unknown'] as const,
+};
+
+export const chatMutations = {
+ createRoom: () =>
+ mutationOptions({
+ mutationKey: chatMutationKeys.createRoom(),
+ mutationFn: postChatRooms,
+ }),
+ createAdminRoom: () =>
+ mutationOptions({
+ mutationKey: chatMutationKeys.createAdminRoom(),
+ mutationFn: postAdminChatRoom,
+ }),
+ sendMessage: () =>
+ mutationOptions({
+ mutationKey: chatMutationKeys.sendMessage(),
+ mutationFn: postChatMessage,
+ }),
+ toggleMute: (chatRoomId?: number) =>
+ mutationOptions({
+ mutationKey: chatMutationKeys.toggleMute(chatRoomId),
+ mutationFn: async () => {
+ if (!chatRoomId) {
+ throw new Error('chatRoomId is missing');
+ }
+
+ return postChatMute(chatRoomId);
+ },
+ }),
+};
diff --git a/src/apis/chat/queries.ts b/src/apis/chat/queries.ts
new file mode 100644
index 00000000..2ac95de3
--- /dev/null
+++ b/src/apis/chat/queries.ts
@@ -0,0 +1,32 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import type { ChatMessagesResponse } from './entity';
+import { getChatMessages, getChatRooms } from '.';
+
+export const chatQueryKeys = {
+ all: ['chat'] as const,
+ rooms: () => [...chatQueryKeys.all, 'rooms'] as const,
+ messages: (chatRoomId: number) => [...chatQueryKeys.all, 'messages', chatRoomId] as const,
+ disabledMessages: () => [...chatQueryKeys.all, 'messages', 'disabled'] as const,
+};
+
+export const chatQueries = {
+ rooms: () =>
+ queryOptions({
+ queryKey: chatQueryKeys.rooms(),
+ queryFn: getChatRooms,
+ }),
+ messages: (chatRoomId?: number, limit = 20) =>
+ infiniteQueryOptions({
+ queryKey: chatRoomId ? chatQueryKeys.messages(chatRoomId) : chatQueryKeys.disabledMessages(),
+ queryFn: ({ pageParam }) =>
+ getChatMessages({
+ chatRoomId: chatRoomId!,
+ page: pageParam,
+ limit,
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage: ChatMessagesResponse) =>
+ lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined,
+ enabled: Boolean(chatRoomId),
+ }),
+};
diff --git a/src/apis/client.ts b/src/apis/client.ts
index fcb27348..644a5b0e 100644
--- a/src/apis/client.ts
+++ b/src/apis/client.ts
@@ -1,7 +1,8 @@
import { refreshAccessToken } from '@/apis/auth';
-import type { ApiError, ApiErrorResponse } from '@/interface/error';
import { useAuthStore } from '@/stores/authStore';
-import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect';
+import type { ApiError, ApiErrorResponse } from '@/utils/ts/error/apiError';
+import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/error/errorRedirect';
+import { postNativeMessage } from '@/utils/ts/nativeBridge';
const BASE_URL = import.meta.env.VITE_API_PATH;
@@ -19,8 +20,6 @@ interface FetchOptions> exten
requiresAuth?: boolean;
}
-let refreshPromise: Promise | null = null;
-
export const apiClient = {
get: >(
endPoint: string,
@@ -128,23 +127,62 @@ function buildQuery(params: Record) {
return usp.toString();
}
-async function sendRequest>(
- endPoint: string,
- options: FetchOptions = {},
- timeout: number = 10000
-): Promise {
+function buildUrl(endPoint: string, params?: Record): string {
+ let url = joinUrl(BASE_URL, endPoint);
+ if (params && Object.keys(params).length > 0) {
+ const query = buildQuery(params);
+ if (query) url += `?${query}`;
+ }
+ return url;
+}
+
+function buildFetchOptions(
+ options: FetchOptions
& { method: string },
+ abortSignal: AbortSignal
+): RequestInit {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { headers, body, method, params, requiresAuth, ...restOptions } = options;
- if (!method) {
- throw new Error('HTTP method가 설정되지 않았습니다.');
+ const isPlainObjectOrArray =
+ body !== undefined &&
+ body !== null &&
+ typeof body === 'object' &&
+ (Array.isArray(body) || body.constructor === Object);
+
+ const h: Record = {
+ ...(isPlainObjectOrArray ? { 'Content-Type': 'application/json' } : {}),
+ ...headers,
+ };
+
+ if (requiresAuth) {
+ const accessToken = useAuthStore.getState().getAccessToken();
+ if (accessToken) {
+ h['Authorization'] = `Bearer ${accessToken}`;
+ }
}
- let url = joinUrl(BASE_URL, endPoint);
- if (params && Object.keys(params).length > 0) {
- const query = buildQuery(params as Record);
- if (query) url += `?${query}`;
+ const fetchOpts: RequestInit = {
+ headers: h,
+ method,
+ signal: abortSignal,
+ credentials: 'include',
+ ...restOptions,
+ };
+
+ if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) {
+ fetchOpts.body = isPlainObjectOrArray ? JSON.stringify(body) : (body as BodyInit);
}
+ return fetchOpts;
+}
+
+async function executeFetch(
+ endPoint: string,
+ options: FetchOptions
& { method: string },
+ timeout: number
+): Promise<{ response: Response; timeoutId: ReturnType }> {
+ const url = buildUrl(endPoint, options.params as Record | undefined);
+
const abortController = new AbortController();
let didTimeout = false;
const timeoutId = setTimeout(() => {
@@ -152,44 +190,38 @@ async function sendRequest => {
- const h: Record = {
- ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}),
- ...headers,
- };
+ try {
+ const fetchOpts = buildFetchOptions(options, abortController.signal);
+ const response = await fetch(url, fetchOpts);
+ return { response, timeoutId };
+ } catch (error) {
+ clearTimeout(timeoutId);
+ rethrowFetchError(error, url, didTimeout);
+ }
+}
- if (requiresAuth) {
- const accessToken = useAuthStore.getState().getAccessToken();
- if (accessToken) {
- h['Authorization'] = `Bearer ${accessToken}`;
- }
- }
+async function sendRequest>(
+ endPoint: string,
+ options: FetchOptions = {},
+ timeout: number = 10000,
+ allowRetry: boolean = true
+): Promise {
+ const { method } = options;
- return h;
- };
+ if (!method) {
+ throw new Error('HTTP method가 설정되지 않았습니다.');
+ }
- try {
- const fetchOptions: RequestInit = {
- headers: buildHeaders(),
- method,
- signal: abortController.signal,
- credentials: 'include',
- ...restOptions,
- };
-
- if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) {
- fetchOptions.body =
- typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData)
- ? JSON.stringify(body)
- : (body as BodyInit);
- }
+ const { response, timeoutId } = await executeFetch(
+ endPoint,
+ options as FetchOptions
& { method: string },
+ timeout
+ );
- const response = await fetch(url, fetchOptions);
+ const url = response.url;
- if (response.status === 401 && requiresAuth) {
- clearTimeout(timeoutId);
+ try {
+ if (response.status === 401 && options.requiresAuth && allowRetry) {
return await handleUnauthorized(endPoint, options, timeout);
}
@@ -197,9 +229,12 @@ async function sendRequest(response);
+ return await parseResponse(response);
} catch (error) {
- rethrowFetchError(error, url, didTimeout);
+ if (error instanceof Error && error.name === 'AbortError') {
+ rethrowFetchError(error, url, true);
+ }
+ throw error;
} finally {
clearTimeout(timeoutId);
}
@@ -210,101 +245,25 @@ async function handleUnauthorized,
timeout: number
): Promise {
+ await refreshAuthSession();
+
+ return await sendRequest(endPoint, options, timeout, false);
+}
+
+export async function refreshAuthSession(): Promise {
let newAccessToken: string;
try {
- if (!refreshPromise) {
- refreshPromise = refreshAccessToken();
- }
- newAccessToken = await refreshPromise;
+ newAccessToken = await refreshAccessToken();
} catch {
- // refresh 실패 → 인증 만료, 로그아웃 처리
useAuthStore.getState().clearAuth();
throw new Error('인증이 만료되었습니다.');
- } finally {
- refreshPromise = null;
}
useAuthStore.getState().setAccessToken(newAccessToken);
+ postNativeMessage({ type: 'TOKEN_REFRESH', accessToken: newAccessToken });
- try {
- if (window.ReactNativeWebView) {
- window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'TOKEN_REFRESH', accessToken: newAccessToken }));
- }
- } catch {
- // 브릿지 전달 실패가 인증 흐름을 중단시키지 않도록 무시
- }
-
- // retry 실패는 그대로 throw (로그아웃 처리 안 함)
- return await sendRequestWithoutRetry(endPoint, options, timeout);
-}
-
-async function sendRequestWithoutRetry>(
- endPoint: string,
- options: FetchOptions = {},
- timeout: number = 10000
-): Promise {
- const { headers, body, method, params, requiresAuth, ...restOptions } = options;
-
- if (!method) {
- throw new Error('HTTP method가 설정되지 않았습니다.');
- }
-
- let url = joinUrl(BASE_URL, endPoint);
- if (params && Object.keys(params).length > 0) {
- const query = buildQuery(params as Record);
- if (query) url += `?${query}`;
- }
-
- const abortController = new AbortController();
- let didTimeout = false;
- const timeoutId = setTimeout(() => {
- didTimeout = true;
- abortController.abort();
- }, timeout);
-
- const isJsonBody = body !== undefined && body !== null && !(body instanceof FormData);
-
- try {
- const h: Record = {
- ...(isJsonBody ? { 'Content-Type': 'application/json' } : {}),
- ...headers,
- };
-
- if (requiresAuth) {
- const accessToken = useAuthStore.getState().getAccessToken();
- if (accessToken) {
- h['Authorization'] = `Bearer ${accessToken}`;
- }
- }
-
- const fetchOptions: RequestInit = {
- headers: h,
- method,
- signal: abortController.signal,
- credentials: 'include',
- ...restOptions,
- };
-
- if (body !== undefined && body !== null && !['GET', 'HEAD'].includes(method)) {
- fetchOptions.body =
- typeof body === 'object' && !(body instanceof Blob) && !(body instanceof FormData)
- ? JSON.stringify(body)
- : (body as BodyInit);
- }
-
- const response = await fetch(url, fetchOptions);
-
- if (!response.ok) {
- return await throwApiError(response);
- }
-
- return parseResponse(response);
- } catch (error) {
- rethrowFetchError(error, url, didTimeout);
- } finally {
- clearTimeout(timeoutId);
- }
+ return newAccessToken;
}
async function parseErrorResponse(response: Response): Promise {
@@ -320,16 +279,34 @@ async function parseErrorResponse(response: Response): Promise(response: Response): Promise {
+ if (response.status === 204 || response.headers.get('Content-Length') === '0') {
+ return null as unknown as T;
+ }
+
const contentType = response.headers.get('Content-Type') || '';
+
if (contentType.includes('application/json')) {
+ const responseText = await response.text();
+
+ if (responseText.trim() === '') {
+ return null as unknown as T;
+ }
+
try {
- return await response.json();
+ return JSON.parse(responseText) as T;
} catch {
- return {} as T;
+ const error = new Error('응답 JSON 파싱에 실패했습니다.') as ApiError;
+ error.name = 'ParseError';
+ error.status = response.status;
+ error.statusText = response.statusText;
+ error.url = response.url;
+ throw error;
}
- } else if (contentType.includes('text')) {
+ }
+
+ if (contentType.includes('text')) {
return (await response.text()) as unknown as T;
- } else {
- return null as unknown as T;
}
+
+ return null as unknown as T;
}
diff --git a/src/apis/club/managedMutations.ts b/src/apis/club/managedMutations.ts
new file mode 100644
index 00000000..bd6b2abb
--- /dev/null
+++ b/src/apis/club/managedMutations.ts
@@ -0,0 +1,112 @@
+import { mutationOptions } from '@tanstack/react-query';
+import {
+ deleteMember,
+ deletePreMember,
+ patchClubSettings,
+ patchMemberPosition,
+ patchVicePresident,
+ postAddPreMember,
+ postClubApplicationApprove,
+ postClubApplicationReject,
+ postTransferPresident,
+ putClubFee,
+ putClubInfo,
+ putClubQuestions,
+ putClubRecruitment,
+} from '@/apis/club';
+import type {
+ AddPreMemberRequest,
+ ChangeMemberPositionRequest,
+ ChangeVicePresidentRequest,
+ ClubFeeRequest,
+ ClubInfoRequest,
+ ClubQuestionsRequest,
+ ClubRecruitmentRequest,
+ ClubSettingsPatchRequest,
+ TransferPresidentRequest,
+} from '@/apis/club/entity';
+
+export const managedClubMutationKeys = {
+ updateInfo: (clubId: number) => ['clubs', 'managed', 'updateInfo', clubId] as const,
+ updateFee: (clubId: number) => ['clubs', 'managed', 'updateFee', clubId] as const,
+ upsertRecruitment: (clubId: number) => ['clubs', 'managed', 'upsertRecruitment', clubId] as const,
+ updateQuestions: (clubId: number) => ['clubs', 'managed', 'updateQuestions', clubId] as const,
+ patchSettings: (clubId: number) => ['clubs', 'managed', 'patchSettings', clubId] as const,
+ approveApplication: (clubId: number) => ['clubs', 'managed', 'approveApplication', clubId] as const,
+ rejectApplication: (clubId: number) => ['clubs', 'managed', 'rejectApplication', clubId] as const,
+ transferPresident: (clubId: number) => ['clubs', 'managed', 'transferPresident', clubId] as const,
+ changeVicePresident: (clubId: number) => ['clubs', 'managed', 'changeVicePresident', clubId] as const,
+ changeMemberPosition: (clubId: number) => ['clubs', 'managed', 'changeMemberPosition', clubId] as const,
+ removeMember: (clubId: number) => ['clubs', 'managed', 'removeMember', clubId] as const,
+ addPreMember: (clubId: number) => ['clubs', 'managed', 'addPreMember', clubId] as const,
+ deletePreMember: (clubId: number) => ['clubs', 'managed', 'deletePreMember', clubId] as const,
+};
+
+export const managedClubMutations = {
+ updateInfo: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.updateInfo(clubId),
+ mutationFn: (data: ClubInfoRequest) => putClubInfo(clubId, data),
+ }),
+ updateFee: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.updateFee(clubId),
+ mutationFn: (data: ClubFeeRequest) => putClubFee(clubId, data),
+ }),
+ upsertRecruitment: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.upsertRecruitment(clubId),
+ mutationFn: (data: ClubRecruitmentRequest) => putClubRecruitment(clubId, data),
+ }),
+ updateQuestions: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.updateQuestions(clubId),
+ mutationFn: (data: ClubQuestionsRequest) => putClubQuestions(clubId, data),
+ }),
+ patchSettings: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.patchSettings(clubId),
+ mutationFn: (data: ClubSettingsPatchRequest) => patchClubSettings(clubId, data),
+ }),
+ approveApplication: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.approveApplication(clubId),
+ mutationFn: (applicationId: number) => postClubApplicationApprove(clubId, applicationId),
+ }),
+ rejectApplication: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.rejectApplication(clubId),
+ mutationFn: (applicationId: number) => postClubApplicationReject(clubId, applicationId),
+ }),
+ transferPresident: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.transferPresident(clubId),
+ mutationFn: (data: TransferPresidentRequest) => postTransferPresident(clubId, data),
+ }),
+ changeVicePresident: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.changeVicePresident(clubId),
+ mutationFn: (data: ChangeVicePresidentRequest) => patchVicePresident(clubId, data),
+ }),
+ changeMemberPosition: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.changeMemberPosition(clubId),
+ mutationFn: ({ userId, data }: { data: ChangeMemberPositionRequest; userId: number }) =>
+ patchMemberPosition(clubId, userId, data),
+ }),
+ removeMember: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.removeMember(clubId),
+ mutationFn: (userId: number) => deleteMember(clubId, userId),
+ }),
+ addPreMember: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.addPreMember(clubId),
+ mutationFn: (data: AddPreMemberRequest) => postAddPreMember(clubId, data),
+ }),
+ deletePreMember: (clubId: number) =>
+ mutationOptions({
+ mutationKey: managedClubMutationKeys.deletePreMember(clubId),
+ mutationFn: (preMemberId: number) => deletePreMember(clubId, preMemberId),
+ }),
+};
diff --git a/src/apis/club/managedQueries.ts b/src/apis/club/managedQueries.ts
new file mode 100644
index 00000000..01d9abcf
--- /dev/null
+++ b/src/apis/club/managedQueries.ts
@@ -0,0 +1,159 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import {
+ getBanks,
+ getClubFee,
+ getClubMembers,
+ getClubQuestions,
+ getClubRecruitment,
+ getClubSettings,
+ getManagedClub,
+ getManagedClubApplicationDetail,
+ getManagedClubApplications,
+ getManagedClubMemberApplicationByUser,
+ getManagedClubMemberApplications,
+ getManagedClubs,
+ getPreMembers,
+} from '@/apis/club';
+import { isApiError } from '@/utils/ts/error/apiError';
+
+interface ManagedClubApplicationsParams {
+ clubId: number;
+ limit: number;
+}
+
+interface ManagedClubMemberApplicationsParams {
+ clubId: number;
+ limit: number;
+ page: number;
+}
+
+export const managedClubQueryKeys = {
+ all: ['clubs', 'managed'] as const,
+ clubs: () => [...managedClubQueryKeys.all, 'clubs'] as const,
+ club: (clubId: number) => [...managedClubQueryKeys.all, 'club', clubId] as const,
+ applications: (clubId: number) => [...managedClubQueryKeys.all, 'applications', clubId] as const,
+ applicationsInfinite: ({ clubId, limit }: ManagedClubApplicationsParams) =>
+ [...managedClubQueryKeys.applications(clubId), 'infinite', limit] as const,
+ applicationDetail: (clubId: number, applicationId: number) =>
+ [...managedClubQueryKeys.all, 'applicationDetail', clubId, applicationId] as const,
+ memberApplications: ({ clubId, page, limit }: ManagedClubMemberApplicationsParams) =>
+ [...managedClubQueryKeys.all, 'memberApplications', clubId, page, limit] as const,
+ memberApplicationDetail: (clubId: number, userId: number) =>
+ [...managedClubQueryKeys.all, 'memberApplicationDetail', clubId, userId] as const,
+ banks: () => [...managedClubQueryKeys.all, 'banks'] as const,
+ fee: (clubId: number) => [...managedClubQueryKeys.all, 'fee', clubId] as const,
+ recruitment: (clubId: number) => [...managedClubQueryKeys.all, 'recruitment', clubId] as const,
+ questions: (clubId: number) => [...managedClubQueryKeys.all, 'questions', clubId] as const,
+ settings: (clubId: number) => [...managedClubQueryKeys.all, 'settings', clubId] as const,
+ members: (clubId: number) => [...managedClubQueryKeys.all, 'members', clubId] as const,
+ preMembers: (clubId: number) => [...managedClubQueryKeys.all, 'preMembers', clubId] as const,
+};
+
+export const managedClubQueries = {
+ clubs: () =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.clubs(),
+ queryFn: getManagedClubs,
+ }),
+ club: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.club(clubId),
+ queryFn: () => getManagedClub(clubId),
+ }),
+ applications: ({ clubId, limit }: ManagedClubApplicationsParams) =>
+ infiniteQueryOptions({
+ queryKey: managedClubQueryKeys.applicationsInfinite({ clubId, limit }),
+ queryFn: async ({ pageParam = 1 }) => {
+ try {
+ return await getManagedClubApplications(clubId, {
+ page: pageParam,
+ limit,
+ sortBy: 'APPLIED_AT',
+ sortDirection: 'ASC',
+ });
+ } catch (error) {
+ if (isApiError(error) && error.apiError?.code === 'NOT_FOUND_CLUB_RECRUITMENT') {
+ return null;
+ }
+
+ throw error;
+ }
+ },
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ if (!lastPage || lastPage.currentPage >= lastPage.totalPage) {
+ return undefined;
+ }
+
+ return lastPage.currentPage + 1;
+ },
+ }),
+ applicationDetail: (clubId: number, applicationId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.applicationDetail(clubId, applicationId),
+ queryFn: () => getManagedClubApplicationDetail(clubId, applicationId),
+ }),
+ memberApplications: ({ clubId, page, limit }: ManagedClubMemberApplicationsParams) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.memberApplications({ clubId, page, limit }),
+ queryFn: () =>
+ getManagedClubMemberApplications(clubId, {
+ page,
+ limit,
+ sortBy: 'APPLIED_AT',
+ sortDirection: 'ASC',
+ }),
+ }),
+ memberApplicationDetail: (clubId: number, userId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.memberApplicationDetail(clubId, userId),
+ queryFn: async () => {
+ try {
+ return await getManagedClubMemberApplicationByUser(clubId, userId);
+ } catch (error) {
+ if (isApiError(error) && error.apiError?.code === 'NOT_FOUND_CLUB_APPLY') {
+ return null;
+ }
+
+ throw error;
+ }
+ },
+ }),
+ banks: () =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.banks(),
+ queryFn: getBanks,
+ }),
+ fee: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.fee(clubId),
+ queryFn: () => getClubFee(clubId),
+ }),
+ recruitment: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.recruitment(clubId),
+ queryFn: () => getClubRecruitment(clubId),
+ retry: false,
+ }),
+ questions: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.questions(clubId),
+ queryFn: () => getClubQuestions(clubId),
+ }),
+ settings: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.settings(clubId),
+ queryFn: () => getClubSettings(clubId),
+ retry: false,
+ }),
+ members: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.members(clubId),
+ queryFn: () => getClubMembers(clubId),
+ }),
+ preMembers: (clubId: number) =>
+ queryOptions({
+ queryKey: managedClubQueryKeys.preMembers(clubId),
+ queryFn: () => getPreMembers(clubId),
+ }),
+};
diff --git a/src/apis/club/mutations.ts b/src/apis/club/mutations.ts
new file mode 100644
index 00000000..0ab7ed2e
--- /dev/null
+++ b/src/apis/club/mutations.ts
@@ -0,0 +1,15 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { applyClub } from '@/apis/club';
+import type { ClubApplyRequest } from '@/apis/club/entity';
+
+export const clubMutationKeys = {
+ apply: (clubId: number) => ['clubs', 'apply', clubId] as const,
+};
+
+export const clubMutations = {
+ apply: (clubId: number) =>
+ mutationOptions({
+ mutationKey: clubMutationKeys.apply(clubId),
+ mutationFn: (body: ClubApplyRequest) => applyClub(clubId, body),
+ }),
+};
diff --git a/src/apis/club/queries.ts b/src/apis/club/queries.ts
index 25539d26..94f0b2d1 100644
--- a/src/apis/club/queries.ts
+++ b/src/apis/club/queries.ts
@@ -1,27 +1,102 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import type { ClubRequestParams, ClubResponse, PositionType } from './entity';
+import {
+ getAppliedClubs,
+ getClubDetail,
+ getClubFee,
+ getClubMembers,
+ getClubQuestions,
+ getClubRecruitment,
+ getClubs,
+ getJoinedClubs,
+} from '.';
+
+interface ClubInfiniteListParams {
+ limit: number;
+ query?: string;
+ isRecruiting?: boolean;
+}
+
export const clubQueryKeys = {
all: ['clubs'] as const,
- list: (params: { limit: number; query?: string; isRecruiting: boolean }) => [
- ...clubQueryKeys.all,
- 'list',
- params.limit,
- params.query,
- params.isRecruiting,
- ],
+ list: (params: { limit: number; query?: string; isRecruiting: boolean }) =>
+ [...clubQueryKeys.all, 'list', params.limit, params.query, params.isRecruiting] as const,
infinite: {
all: () => [...clubQueryKeys.all, 'infinite'] as const,
- list: (params: { limit: number; query?: string; isRecruiting?: boolean }) => [
- ...clubQueryKeys.infinite.all(),
- 'list',
- params.limit,
- params.query,
- params.isRecruiting,
- ],
+ list: (params: ClubInfiniteListParams) =>
+ [...clubQueryKeys.infinite.all(), 'list', params.limit, params.query, params.isRecruiting] as const,
},
- detail: (clubId: number) => [...clubQueryKeys.all, 'detail', clubId],
- members: (clubId: number) => [...clubQueryKeys.all, 'members', clubId],
- recruitment: (clubId: number) => [...clubQueryKeys.all, 'recruitment', clubId],
- fee: (clubId: number) => [...clubQueryKeys.all, 'fee', clubId],
- questions: (clubId: number) => [...clubQueryKeys.all, 'questions', clubId],
- joined: () => [...clubQueryKeys.all, 'joined'],
- applied: () => [...clubQueryKeys.all, 'applied'],
+ detail: (clubId: number) => [...clubQueryKeys.all, 'detail', clubId] as const,
+ members: (clubId: number, position?: PositionType) =>
+ position
+ ? ([...clubQueryKeys.all, 'members', clubId, position] as const)
+ : ([...clubQueryKeys.all, 'members', clubId] as const),
+ membersDisabled: () => [...clubQueryKeys.all, 'members', 'disabled'] as const,
+ recruitment: (clubId: number) => [...clubQueryKeys.all, 'recruitment', clubId] as const,
+ fee: (clubId: number) => [...clubQueryKeys.all, 'fee', clubId] as const,
+ questions: (clubId: number) => [...clubQueryKeys.all, 'questions', clubId] as const,
+ joined: () => [...clubQueryKeys.all, 'joined'] as const,
+ applied: () => [...clubQueryKeys.all, 'applied'] as const,
+};
+
+const buildClubListRequest = (
+ { limit, query, isRecruiting = false }: ClubInfiniteListParams,
+ page: number
+): ClubRequestParams => ({
+ page,
+ limit,
+ ...(query ? { query } : {}),
+ isRecruiting,
+});
+
+export const clubQueries = {
+ detail: (clubId: number) =>
+ queryOptions({
+ queryKey: clubQueryKeys.detail(clubId),
+ queryFn: () => getClubDetail(clubId),
+ }),
+ members: (clubId?: number, position?: PositionType) =>
+ queryOptions({
+ queryKey: clubId ? clubQueryKeys.members(clubId, position) : clubQueryKeys.membersDisabled(),
+ queryFn: () => getClubMembers(clubId!, position),
+ enabled: Boolean(clubId),
+ }),
+ recruitment: (clubId: number) =>
+ queryOptions({
+ queryKey: clubQueryKeys.recruitment(clubId),
+ queryFn: () => getClubRecruitment(clubId),
+ }),
+ fee: (clubId: number) =>
+ queryOptions({
+ queryKey: clubQueryKeys.fee(clubId),
+ queryFn: () => getClubFee(clubId),
+ }),
+ questions: (clubId: number) =>
+ queryOptions({
+ queryKey: clubQueryKeys.questions(clubId),
+ queryFn: () => getClubQuestions(clubId),
+ }),
+ joined: () =>
+ queryOptions({
+ queryKey: clubQueryKeys.joined(),
+ queryFn: getJoinedClubs,
+ }),
+ applied: () =>
+ queryOptions({
+ queryKey: clubQueryKeys.applied(),
+ queryFn: getAppliedClubs,
+ }),
+ infiniteList: (params: ClubInfiniteListParams) =>
+ infiniteQueryOptions({
+ queryKey: clubQueryKeys.infinite.list(params),
+ queryFn: ({ pageParam }) => getClubs(buildClubListRequest(params, pageParam)),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage: ClubResponse) => {
+ if (lastPage.currentPage < lastPage.totalPage) {
+ return lastPage.currentPage + 1;
+ }
+
+ return undefined;
+ },
+ }),
};
diff --git a/src/apis/council/queries.ts b/src/apis/council/queries.ts
new file mode 100644
index 00000000..6e90f1a2
--- /dev/null
+++ b/src/apis/council/queries.ts
@@ -0,0 +1,42 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import type { NoticeResponse } from './entity';
+import { getCouncilInfo, getCouncilNotice, getCouncilNoticeDetail } from '.';
+
+export const councilQueryKeys = {
+ all: ['council'] as const,
+ info: () => [...councilQueryKeys.all, 'info'] as const,
+ notices: (limit: number) => [...councilQueryKeys.all, 'notices', limit] as const,
+ noticesPreview: (limit: number) => [...councilQueryKeys.all, 'noticesPreview', limit] as const,
+ noticeDetail: (noticeId: number) => [...councilQueryKeys.all, 'noticeDetail', noticeId] as const,
+};
+
+export const councilQueries = {
+ info: () =>
+ queryOptions({
+ queryKey: councilQueryKeys.info(),
+ queryFn: getCouncilInfo,
+ }),
+ noticesPreview: (limit = 3) =>
+ queryOptions({
+ queryKey: councilQueryKeys.noticesPreview(limit),
+ queryFn: () => getCouncilNotice({ page: 1, limit }),
+ }),
+ noticeDetail: (noticeId: number) =>
+ queryOptions({
+ queryKey: councilQueryKeys.noticeDetail(noticeId),
+ queryFn: () => getCouncilNoticeDetail(noticeId),
+ }),
+ infiniteNotices: (limit = 10) =>
+ infiniteQueryOptions({
+ queryKey: councilQueryKeys.notices(limit),
+ queryFn: ({ pageParam }) => getCouncilNotice({ page: pageParam, limit }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage: NoticeResponse) => {
+ if (lastPage.currentPage >= lastPage.totalPage) {
+ return undefined;
+ }
+
+ return lastPage.currentPage + 1;
+ },
+ }),
+};
diff --git a/src/apis/inquiry/mutations.ts b/src/apis/inquiry/mutations.ts
new file mode 100644
index 00000000..6aa67a76
--- /dev/null
+++ b/src/apis/inquiry/mutations.ts
@@ -0,0 +1,14 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { postInquiry } from '@/apis/inquiry';
+
+export const inquiryMutationKeys = {
+ create: () => ['inquiry', 'create'] as const,
+};
+
+export const inquiryMutations = {
+ create: () =>
+ mutationOptions({
+ mutationKey: inquiryMutationKeys.create(),
+ mutationFn: postInquiry,
+ }),
+};
diff --git a/src/apis/notification/cache.ts b/src/apis/notification/cache.ts
new file mode 100644
index 00000000..2ddba418
--- /dev/null
+++ b/src/apis/notification/cache.ts
@@ -0,0 +1,77 @@
+import type {
+ InboxNotification,
+ InboxNotificationListResponse,
+ InboxNotificationUnreadCountResponse,
+} from '@/apis/notification/entity';
+import type { InfiniteData } from '@tanstack/react-query';
+
+function hasInboxNotification(previousData: InfiniteData, notificationId: number) {
+ return previousData.pages.some((page) =>
+ page.notifications.some((notification) => notification.id === notificationId)
+ );
+}
+
+export function prependInboxNotification(
+ previousData: InfiniteData | undefined,
+ notification: InboxNotification
+) {
+ if (!previousData || hasInboxNotification(previousData, notification.id)) {
+ return previousData;
+ }
+
+ const [firstPage, ...remainingPages] = previousData.pages;
+
+ if (!firstPage) {
+ return previousData;
+ }
+
+ return {
+ ...previousData,
+ pages: [
+ {
+ ...firstPage,
+ notifications: [notification, ...firstPage.notifications],
+ totalElements: firstPage.totalElements + 1,
+ },
+ ...remainingPages.map((page) => ({
+ ...page,
+ totalElements: page.totalElements + 1,
+ })),
+ ],
+ };
+}
+
+export function setInboxNotificationReadState(
+ previousData: InfiniteData | undefined,
+ notificationId: number
+) {
+ if (!previousData) {
+ return previousData;
+ }
+
+ return {
+ ...previousData,
+ pages: previousData.pages.map((page) => ({
+ ...page,
+ notifications: page.notifications.map((notification) =>
+ notification.id === notificationId ? { ...notification, isRead: true } : notification
+ ),
+ })),
+ };
+}
+
+export function incrementInboxUnreadCount(previousData: InboxNotificationUnreadCountResponse | undefined, count = 1) {
+ return {
+ unreadCount: Math.max((previousData?.unreadCount ?? 0) + count, 0),
+ };
+}
+
+export function decrementInboxUnreadCount(previousData: InboxNotificationUnreadCountResponse | undefined, count = 1) {
+ if (!previousData) {
+ return previousData;
+ }
+
+ return {
+ unreadCount: Math.max(previousData.unreadCount - count, 0),
+ };
+}
diff --git a/src/apis/notification/entity.ts b/src/apis/notification/entity.ts
new file mode 100644
index 00000000..17d5ffd9
--- /dev/null
+++ b/src/apis/notification/entity.ts
@@ -0,0 +1,31 @@
+export type NotificationInboxType =
+ | 'CLUB_APPLICATION_SUBMITTED'
+ | 'CLUB_APPLICATION_APPROVED'
+ | 'CLUB_APPLICATION_REJECTED'
+ | 'CHAT_MESSAGE'
+ | 'GROUP_CHAT_MESSAGE'
+ | 'UNREAD_CHAT_COUNT';
+
+export type InboxNotificationType = NotificationInboxType | (string & {});
+
+export interface InboxNotification {
+ id: number;
+ type: InboxNotificationType;
+ title: string;
+ body: string;
+ path: string;
+ isRead: boolean;
+ createdAt: string;
+}
+
+export interface InboxNotificationListResponse {
+ notifications: InboxNotification[];
+ currentPage: number;
+ totalPages: number;
+ totalElements: number;
+ hasNext: boolean;
+}
+
+export interface InboxNotificationUnreadCountResponse {
+ unreadCount: number;
+}
diff --git a/src/apis/notification/index.ts b/src/apis/notification/index.ts
index d90d626d..8248c2c9 100644
--- a/src/apis/notification/index.ts
+++ b/src/apis/notification/index.ts
@@ -1,4 +1,5 @@
-import { apiClient } from '../client';
+import { apiClient } from '@/apis/client';
+import type { InboxNotificationListResponse, InboxNotificationUnreadCountResponse } from '@/apis/notification/entity';
export const registerPushToken = async (token: string) => {
if (window.ReactNativeWebView) {
@@ -21,3 +22,25 @@ export const getNotificationToken = async (): Promise<{ token: string }> => {
});
return response;
};
+
+export const getInboxNotifications = async (page = 1): Promise => {
+ const response = await apiClient.get('notifications/inbox', {
+ params: { page },
+ requiresAuth: true,
+ });
+ return response;
+};
+
+export const getInboxUnreadCount = async (): Promise => {
+ const response = await apiClient.get('notifications/inbox/unread-count', {
+ requiresAuth: true,
+ });
+ return response;
+};
+
+export const markInboxNotificationAsRead = async (notificationId: number) => {
+ const response = await apiClient.patch(`notifications/inbox/${notificationId}/read`, {
+ requiresAuth: true,
+ });
+ return response;
+};
diff --git a/src/apis/notification/mutations.ts b/src/apis/notification/mutations.ts
new file mode 100644
index 00000000..14479719
--- /dev/null
+++ b/src/apis/notification/mutations.ts
@@ -0,0 +1,19 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { markInboxNotificationAsRead } from '@/apis/notification';
+
+export interface MarkInboxNotificationAsReadVariables {
+ notificationId: number;
+}
+
+export const notificationMutationKeys = {
+ markInboxAsRead: () => ['notifications', 'inbox', 'mark-as-read'] as const,
+};
+
+export const notificationMutations = {
+ markInboxAsRead: () =>
+ mutationOptions({
+ mutationKey: notificationMutationKeys.markInboxAsRead(),
+ mutationFn: ({ notificationId }: MarkInboxNotificationAsReadVariables) =>
+ markInboxNotificationAsRead(notificationId),
+ }),
+};
diff --git a/src/apis/notification/queries.ts b/src/apis/notification/queries.ts
new file mode 100644
index 00000000..71f6f9f6
--- /dev/null
+++ b/src/apis/notification/queries.ts
@@ -0,0 +1,34 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import { getInboxNotifications, getInboxUnreadCount, getNotificationToken } from '.';
+
+export const notificationQueryKeys = {
+ all: ['notifications'] as const,
+ token: () => [...notificationQueryKeys.all, 'token'] as const,
+ inbox: {
+ all: () => [...notificationQueryKeys.all, 'inbox'] as const,
+ list: () => [...notificationQueryKeys.inbox.all(), 'list'] as const,
+ infinite: () => [...notificationQueryKeys.inbox.list(), 'infinite'] as const,
+ unreadCount: () => [...notificationQueryKeys.inbox.all(), 'unread-count'] as const,
+ },
+};
+
+export const notificationQueries = {
+ token: () =>
+ queryOptions({
+ queryKey: notificationQueryKeys.token(),
+ queryFn: getNotificationToken,
+ retry: false,
+ }),
+ inboxUnreadCount: () =>
+ queryOptions({
+ queryKey: notificationQueryKeys.inbox.unreadCount(),
+ queryFn: getInboxUnreadCount,
+ }),
+ inboxInfinite: () =>
+ infiniteQueryOptions({
+ queryKey: notificationQueryKeys.inbox.infinite(),
+ queryFn: ({ pageParam = 1 }) => getInboxNotifications(pageParam),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => (lastPage.hasNext ? lastPage.currentPage + 1 : undefined),
+ }),
+};
diff --git a/src/apis/schedule/queries.ts b/src/apis/schedule/queries.ts
new file mode 100644
index 00000000..eff138b5
--- /dev/null
+++ b/src/apis/schedule/queries.ts
@@ -0,0 +1,22 @@
+import { queryOptions } from '@tanstack/react-query';
+import type { ScheduleRequestParams } from './entity';
+import { getScheduleList, getUpComingScheduleList } from '.';
+
+export const scheduleQueryKeys = {
+ all: ['schedules'] as const,
+ monthly: (params: ScheduleRequestParams) => [...scheduleQueryKeys.all, 'monthly', params.year, params.month] as const,
+ upcoming: () => [...scheduleQueryKeys.all, 'upcoming'] as const,
+};
+
+export const scheduleQueries = {
+ monthly: (params: ScheduleRequestParams) =>
+ queryOptions({
+ queryKey: scheduleQueryKeys.monthly(params),
+ queryFn: () => getScheduleList(params),
+ }),
+ upcoming: () =>
+ queryOptions({
+ queryKey: scheduleQueryKeys.upcoming(),
+ queryFn: getUpComingScheduleList,
+ }),
+};
diff --git a/src/apis/studyTime/hooks.ts b/src/apis/studyTime/hooks.ts
new file mode 100644
index 00000000..0541ddaa
--- /dev/null
+++ b/src/apis/studyTime/hooks.ts
@@ -0,0 +1,33 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { studyTimeMutations } from '@/apis/studyTime/mutations';
+import { studyTimeQueryKeys } from '@/apis/studyTime/queries';
+import { API_ERROR_CODES, isApiError } from '@/utils/ts/error/apiError';
+
+export const useStopStudyTimerMutation = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...studyTimeMutations.stopTimer(),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: studyTimeQueryKeys.summary() });
+ },
+ });
+};
+
+export const useStartStudyTimerMutation = () => {
+ const stopMutation = useStopStudyTimerMutation();
+
+ return useMutation({
+ ...studyTimeMutations.startTimer(),
+ onError: async (error) => {
+ if (!isApiError(error)) throw error;
+
+ // 이미 실행 중인 타이머가 있으면 정리 후 재시도
+ if (error.apiError?.code === API_ERROR_CODES.ALREADY_RUNNING_STUDY_TIMER) {
+ await stopMutation.mutateAsync({ totalSeconds: 0 });
+ }
+
+ throw error;
+ },
+ });
+};
diff --git a/src/apis/studyTime/mutations.ts b/src/apis/studyTime/mutations.ts
new file mode 100644
index 00000000..092ff482
--- /dev/null
+++ b/src/apis/studyTime/mutations.ts
@@ -0,0 +1,21 @@
+import { mutationOptions } from '@tanstack/react-query';
+import { startStudyTimer, stopStudyTimer } from '@/apis/studyTime';
+import type { StopTimerRequest } from '@/apis/studyTime/entity';
+
+export const studyTimeMutationKeys = {
+ startTimer: () => ['studyTime', 'startTimer'] as const,
+ stopTimer: () => ['studyTime', 'stopTimer'] as const,
+};
+
+export const studyTimeMutations = {
+ startTimer: () =>
+ mutationOptions({
+ mutationKey: studyTimeMutationKeys.startTimer(),
+ mutationFn: startStudyTimer,
+ }),
+ stopTimer: () =>
+ mutationOptions({
+ mutationKey: studyTimeMutationKeys.stopTimer(),
+ mutationFn: (data: StopTimerRequest) => stopStudyTimer(data),
+ }),
+};
diff --git a/src/apis/studyTime/queries.ts b/src/apis/studyTime/queries.ts
new file mode 100644
index 00000000..908bfb4d
--- /dev/null
+++ b/src/apis/studyTime/queries.ts
@@ -0,0 +1,55 @@
+import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
+import { getMyStudyTimeRanking, getStudyTimeRanking, getStudyTimeSummary } from '@/apis/studyTime';
+import type { StudyRankingParams } from '@/apis/studyTime/entity';
+
+interface StudyTimeRankingKeyParams {
+ limit: number;
+ sort: StudyRankingParams['sort'];
+ type: StudyRankingParams['type'];
+}
+
+interface MyStudyTimeRankingKeyParams {
+ sort: StudyRankingParams['sort'];
+ type: StudyRankingParams['type'];
+}
+
+export const studyTimeQueryKeys = {
+ all: ['studyTime'] as const,
+ summary: () => [...studyTimeQueryKeys.all, 'summary'] as const,
+ ranking: (params: StudyTimeRankingKeyParams) =>
+ [...studyTimeQueryKeys.all, 'ranking', params.limit, params.sort, params.type] as const,
+ myRanking: (params: MyStudyTimeRankingKeyParams) => [...studyTimeQueryKeys.all, 'myRanking', params.sort] as const,
+};
+
+export const studyTimeQueries = {
+ summary: () =>
+ queryOptions({
+ queryKey: studyTimeQueryKeys.summary(),
+ queryFn: getStudyTimeSummary,
+ }),
+ ranking: ({ limit, sort, type }: StudyTimeRankingKeyParams) =>
+ infiniteQueryOptions({
+ queryKey: studyTimeQueryKeys.ranking({ limit, sort, type }),
+ queryFn: ({ pageParam }) =>
+ getStudyTimeRanking({
+ page: pageParam,
+ limit,
+ sort,
+ type,
+ }),
+ initialPageParam: 1,
+ getNextPageParam: (lastPage) => {
+ return lastPage.currentPage < lastPage.totalPage ? lastPage.currentPage + 1 : undefined;
+ },
+ }),
+ myRanking: ({ sort, type }: MyStudyTimeRankingKeyParams) =>
+ queryOptions({
+ queryKey: studyTimeQueryKeys.myRanking({ sort, type }),
+ queryFn: () => getMyStudyTimeRanking({ sort }),
+ select: (data) => {
+ if (type === 'CLUB') return data.clubRankings;
+ if (type === 'STUDENT_NUMBER') return [data.studentNumberRanking];
+ return [data.personalRanking];
+ },
+ }),
+};
diff --git a/src/apis/university/queries.ts b/src/apis/university/queries.ts
new file mode 100644
index 00000000..032fa060
--- /dev/null
+++ b/src/apis/university/queries.ts
@@ -0,0 +1,15 @@
+import { queryOptions } from '@tanstack/react-query';
+import { getUniversityList } from '.';
+
+export const universityQueryKeys = {
+ all: ['university'] as const,
+ list: () => [...universityQueryKeys.all, 'list'] as const,
+};
+
+export const universityQueries = {
+ list: () =>
+ queryOptions({
+ queryKey: universityQueryKeys.list(),
+ queryFn: getUniversityList,
+ }),
+};
diff --git a/src/apis/upload/mutations.ts b/src/apis/upload/mutations.ts
new file mode 100644
index 00000000..d2940f0d
--- /dev/null
+++ b/src/apis/upload/mutations.ts
@@ -0,0 +1,15 @@
+import { mutationOptions } from '@tanstack/react-query';
+import type { UploadTarget } from './entity';
+import { uploadImage } from '.';
+
+export const uploadMutationKeys = {
+ image: (target: UploadTarget) => ['upload', 'image', target] as const,
+};
+
+export const uploadMutations = {
+ image: (target: UploadTarget) =>
+ mutationOptions({
+ mutationKey: uploadMutationKeys.image(target),
+ mutationFn: (file: File) => uploadImage(file, target),
+ }),
+};
diff --git a/src/assets/image/bottom-nav-home.png b/src/assets/image/bottom-nav-home.png
new file mode 100644
index 00000000..8c1899e5
Binary files /dev/null and b/src/assets/image/bottom-nav-home.png differ
diff --git a/src/assets/image/chat-cat-header.png b/src/assets/image/chat-cat-header.png
deleted file mode 100644
index 550a5666..00000000
Binary files a/src/assets/image/chat-cat-header.png and /dev/null differ
diff --git a/src/assets/image/notification-toast-approved.png b/src/assets/image/notification-toast-approved.png
new file mode 100644
index 00000000..0cd1246d
Binary files /dev/null and b/src/assets/image/notification-toast-approved.png differ
diff --git a/src/assets/image/notification-toast-general.png b/src/assets/image/notification-toast-general.png
new file mode 100644
index 00000000..29e4ba00
Binary files /dev/null and b/src/assets/image/notification-toast-general.png differ
diff --git a/src/assets/svg/add_circle.svg b/src/assets/svg/add_circle.svg
new file mode 100644
index 00000000..e70fca95
--- /dev/null
+++ b/src/assets/svg/add_circle.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/svg/bottom-nav-chat.svg b/src/assets/svg/bottom-nav-chat.svg
new file mode 100644
index 00000000..ea888886
--- /dev/null
+++ b/src/assets/svg/bottom-nav-chat.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/svg/bottom-nav-clubs.svg b/src/assets/svg/bottom-nav-clubs.svg
new file mode 100644
index 00000000..3be29f33
--- /dev/null
+++ b/src/assets/svg/bottom-nav-clubs.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/svg/bottom-nav-mypage.svg b/src/assets/svg/bottom-nav-mypage.svg
new file mode 100644
index 00000000..8b85ae84
--- /dev/null
+++ b/src/assets/svg/bottom-nav-mypage.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/svg/bottom-nav-sms.svg b/src/assets/svg/bottom-nav-sms.svg
new file mode 100644
index 00000000..81fe72fd
--- /dev/null
+++ b/src/assets/svg/bottom-nav-sms.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/svg/bottom-nav-timer.svg b/src/assets/svg/bottom-nav-timer.svg
new file mode 100644
index 00000000..3dd05583
--- /dev/null
+++ b/src/assets/svg/bottom-nav-timer.svg
@@ -0,0 +1,6 @@
+
diff --git a/src/assets/svg/chat-icon.svg b/src/assets/svg/chat-icon.svg
new file mode 100644
index 00000000..7ebdc44f
--- /dev/null
+++ b/src/assets/svg/chat-icon.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/svg/clock.svg b/src/assets/svg/clock.svg
index 068bd52f..1a502a89 100644
--- a/src/assets/svg/clock.svg
+++ b/src/assets/svg/clock.svg
@@ -1,3 +1,3 @@
diff --git a/src/assets/svg/instagram.svg b/src/assets/svg/instagram.svg
index 91325d78..909561d6 100644
--- a/src/assets/svg/instagram.svg
+++ b/src/assets/svg/instagram.svg
@@ -1,3 +1,3 @@
diff --git a/src/assets/svg/location-pin.svg b/src/assets/svg/location-pin.svg
index 143251cc..c8f3fa16 100644
--- a/src/assets/svg/location-pin.svg
+++ b/src/assets/svg/location-pin.svg
@@ -1,4 +1,4 @@
diff --git a/src/assets/svg/notifications.svg b/src/assets/svg/notifications.svg
new file mode 100644
index 00000000..f2b7850e
--- /dev/null
+++ b/src/assets/svg/notifications.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/assets/svg/person-icon.svg b/src/assets/svg/person-icon.svg
new file mode 100644
index 00000000..9fc3dba6
--- /dev/null
+++ b/src/assets/svg/person-icon.svg
@@ -0,0 +1,8 @@
+
diff --git a/src/assets/svg/unread-notification.svg b/src/assets/svg/unread-notification.svg
new file mode 100644
index 00000000..0c829e31
--- /dev/null
+++ b/src/assets/svg/unread-notification.svg
@@ -0,0 +1,19 @@
+
diff --git a/src/components/common/Portal.tsx b/src/components/common/Portal.tsx
index b9b2d5c7..cc3e7403 100644
--- a/src/components/common/Portal.tsx
+++ b/src/components/common/Portal.tsx
@@ -3,9 +3,13 @@ import { createPortal } from 'react-dom';
interface PortalProps {
children: ReactNode;
- container?: Element;
+ container?: Element | DocumentFragment | null;
}
export default function Portal({ children, container = document.body }: PortalProps) {
+ if (!container) {
+ return null;
+ }
+
return createPortal(children, container);
}
diff --git a/src/components/layout/BottomNav/index.tsx b/src/components/layout/BottomNav/index.tsx
index f2733f7d..8dfa8ffa 100644
--- a/src/components/layout/BottomNav/index.tsx
+++ b/src/components/layout/BottomNav/index.tsx
@@ -1,45 +1,123 @@
-import { type ComponentType, type SVGProps } from 'react';
-import { NavLink } from 'react-router-dom';
-import HouseIcon from '@/assets/svg/house.svg';
-import PeopleIcon from '@/assets/svg/people.svg';
-import PersonIcon from '@/assets/svg/person.svg';
-import TimerIcon from '@/assets/svg/timer.svg';
+import { type ComponentType, type Ref, type SVGProps } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+import HomeResultImage from '@/assets/image/bottom-nav-home.png';
+import ClubsIcon from '@/assets/svg/bottom-nav-clubs.svg';
+import MyPageIcon from '@/assets/svg/bottom-nav-mypage.svg';
+import ChatIcon from '@/assets/svg/bottom-nav-sms.svg';
+import TimerIcon from '@/assets/svg/bottom-nav-timer.svg';
+import useUnreadChatCount from '@/pages/Chat/hooks/useUnreadChatCount';
import { cn } from '@/utils/ts/cn';
interface BottomNavItemConfig {
to: string;
label: string;
- Icon: ComponentType>;
- end?: boolean;
+ Icon?: ComponentType>;
+ floatingImageSrc?: string;
+ matchesPath?: (pathname: string) => boolean;
}
const BOTTOM_NAV_ITEMS = [
- { to: '/home', label: '홈', Icon: HouseIcon, end: true },
- { to: '/clubs', label: '동아리', Icon: PeopleIcon },
+ { to: '/clubs', label: '동아리', Icon: ClubsIcon },
{ to: '/timer', label: '타이머', Icon: TimerIcon },
- { to: '/mypage', label: '내정보', Icon: PersonIcon },
+ {
+ to: '/home',
+ label: '홈',
+ floatingImageSrc: HomeResultImage,
+ matchesPath: (pathname) => pathname === '/home' || pathname === '/notifications' || pathname.startsWith('/council'),
+ },
+ { to: '/chats', label: '채팅방', Icon: ChatIcon },
+ { to: '/mypage', label: '내정보', Icon: MyPageIcon },
] satisfies BottomNavItemConfig[];
-function BottomNavItem({ to, label, Icon, end = false }: BottomNavItemConfig) {
+function matchesBottomNavItemPath({ to, matchesPath }: BottomNavItemConfig, pathname: string) {
+ if (matchesPath) {
+ return matchesPath(pathname);
+ }
+
+ return pathname === to || pathname.startsWith(`${to}/`);
+}
+
+function formatUnreadChatCount(unreadCount: number) {
+ if (unreadCount <= 0) {
+ return null;
+ }
+
+ return unreadCount > 99 ? '99+' : String(unreadCount);
+}
+
+interface BottomNavItemProps {
+ item: BottomNavItemConfig;
+ isSelected: boolean;
+ unreadCount?: number;
+}
+
+function BottomNavItem({ item, isSelected, unreadCount = 0 }: BottomNavItemProps) {
+ const { to, label, Icon, floatingImageSrc } = item;
+ const unreadCountLabel = formatUnreadChatCount(unreadCount);
+ const hasUnreadCount = unreadCountLabel !== null;
+
return (
-
- {({ isActive }) => (
- <>
-
- {label}
- >
- )}
-
+
+ {floatingImageSrc ? (
+
+ ) : Icon ? (
+
+
+ {hasUnreadCount && (
+ 2 ? 'min-w-4 px-1' : 'size-4'
+ )}
+ >
+ {unreadCountLabel}
+
+ )}
+
+ ) : null}
+
+ {label}
+
+
);
}
-function BottomNav() {
+interface BottomNavProps {
+ navRef?: Ref;
+}
+
+function BottomNav({ navRef }: BottomNavProps) {
+ const { pathname } = useLocation();
+ const { totalUnreadCount } = useUnreadChatCount();
+
return (
-