diff --git a/.github/ISSUE_TEMPLATE/pull_request_template b/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/ISSUE_TEMPLATE/pull_request_template rename to .github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md diff --git a/public/apple-touch-icon-180x180.png b/public/apple-touch-icon-180x180.png deleted file mode 100644 index a9fb25d3..00000000 Binary files a/public/apple-touch-icon-180x180.png and /dev/null differ diff --git a/public/maskable-icon-512x512.png b/public/maskable-icon-512x512.png deleted file mode 100644 index 0c5779f0..00000000 Binary files a/public/maskable-icon-512x512.png and /dev/null differ diff --git a/public/pwa-192x192.png b/public/pwa-192x192.png deleted file mode 100644 index 87c7b6de..00000000 Binary files a/public/pwa-192x192.png and /dev/null differ diff --git a/public/pwa-512x512.png b/public/pwa-512x512.png deleted file mode 100644 index 149b71f4..00000000 Binary files a/public/pwa-512x512.png and /dev/null differ diff --git a/public/pwa-64x64.png b/public/pwa-64x64.png deleted file mode 100644 index a8e3a56a..00000000 Binary files a/public/pwa-64x64.png and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index 22798bf4..124be5cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ const ManagedMemberList = lazy(() => import('./pages/Manager/ManagedMemberList') const ManagedRecruitment = lazy(() => import('./pages/Manager/ManagedRecruitment')); const ManagedRecruitmentForm = lazy(() => import('./pages/Manager/ManagedRecruitmentForm')); const ManagedRecruitmentWrite = lazy(() => import('./pages/Manager/ManagedRecruitmentWrite')); +const NotificationsPage = lazy(() => import('./pages/Notifications')); const Schedule = lazy(() => import('./pages/Schedule')); const Timer = lazy(() => import('./pages/Timer')); const MyPage = lazy(() => import('./pages/User/MyPage')); @@ -84,6 +85,11 @@ function App() { }> }> } /> + } /> + + } /> + } /> + } /> @@ -97,6 +103,7 @@ function App() { } /> + } /> } /> } /> @@ -113,14 +120,7 @@ function App() { } /> } /> } /> - - } /> - } /> - - - } /> - } /> - + } /> diff --git a/src/apis/advertisement/entity.ts b/src/apis/advertisement/entity.ts new file mode 100644 index 00000000..4f7f826b --- /dev/null +++ b/src/apis/advertisement/entity.ts @@ -0,0 +1,15 @@ +export interface Advertisement { + id: number; + title: string; + description: string; + imageUrl: string; + linkUrl: string; +} + +export interface AdvertisementsRequestParams { + count?: number; +} + +export interface AdvertisementsResponse { + advertisements: Advertisement[]; +} diff --git a/src/apis/advertisement/index.ts b/src/apis/advertisement/index.ts new file mode 100644 index 00000000..fa8b52bd --- /dev/null +++ b/src/apis/advertisement/index.ts @@ -0,0 +1,19 @@ +import { apiClient } from '../client'; +import type { AdvertisementsRequestParams, AdvertisementsResponse } from './entity'; + +export const getAdvertisements = async (params: AdvertisementsRequestParams = {}) => { + const response = await apiClient.get('advertisements', { + params, + requiresAuth: true, + }); + + return response; +}; + +export const postAdvertisementClick = async (advertisementId: number) => { + const response = await apiClient.post(`advertisements/${advertisementId}/clicks`, { + requiresAuth: true, + }); + + return response; +}; diff --git a/src/apis/advertisement/mutations.ts b/src/apis/advertisement/mutations.ts new file mode 100644 index 00000000..65f4f340 --- /dev/null +++ b/src/apis/advertisement/mutations.ts @@ -0,0 +1,15 @@ +import { mutationOptions } from '@tanstack/react-query'; +import { postAdvertisementClick } from '.'; + +export const advertisementMutationKeys = { + click: () => ['advertisements', 'click'] as const, +}; + +export const advertisementMutations = { + click: () => + mutationOptions({ + mutationKey: advertisementMutationKeys.click(), + mutationFn: postAdvertisementClick, + retry: false, + }), +}; diff --git a/src/apis/advertisement/queries.ts b/src/apis/advertisement/queries.ts new file mode 100644 index 00000000..b7e7df7a --- /dev/null +++ b/src/apis/advertisement/queries.ts @@ -0,0 +1,17 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getAdvertisements } from '@/apis/advertisement'; + +export const advertisementQueryKeys = { + all: ['advertisements'] as const, + randomBatch: (scope: string, batchIndex: number) => + [...advertisementQueryKeys.all, scope, 'random-batch', batchIndex] as const, +}; + +export const advertisementQueries = { + randomBatch: (scope: string, batchIndex: number) => + queryOptions({ + queryKey: advertisementQueryKeys.randomBatch(scope, batchIndex), + queryFn: () => getAdvertisements({ count: 2 }), + staleTime: Infinity, + }), +}; diff --git a/src/apis/auth/index.ts b/src/apis/auth/index.ts index 44358caa..c374d6c0 100644 --- a/src/apis/auth/index.ts +++ b/src/apis/auth/index.ts @@ -1,5 +1,5 @@ -import type { ApiError } from '@/interface/error'; -import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/errorRedirect'; +import type { ApiError } from '@/utils/ts/error/apiError'; +import { isServerErrorStatus, redirectToServerErrorPage } from '@/utils/ts/error/errorRedirect'; import { NORMALIZED_API_BASE_URL } from '@/utils/ts/oauth'; import { apiClient } from '../client'; import type { @@ -10,7 +10,9 @@ import type { SignupRequest, } from './entity'; -export const refreshAccessToken = async (): Promise => { +let refreshAccessTokenPromise: Promise | null = null; + +const requestAccessTokenRefresh = async (): Promise => { const url = `${NORMALIZED_API_BASE_URL}/users/refresh`; let response: Response; @@ -49,6 +51,16 @@ export const refreshAccessToken = async (): Promise => { return data.accessToken; }; +export const refreshAccessToken = async (): Promise => { + 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 && ( + + )} +
+ ) : null} + + {label} + + ); } -function BottomNav() { +interface BottomNavProps { + navRef?: Ref; +} + +function BottomNav({ navRef }: BottomNavProps) { + const { pathname } = useLocation(); + const { totalUnreadCount } = useUnreadChatCount(); + return ( -