diff --git a/frontend/.prettierrc b/frontend/.prettierrc index a87ae3184..9ab256712 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -3,14 +3,7 @@ "jsxSingleQuote": true, "useTabs": false, "endOfLine": "auto", - "plugins": [ - "@ianvs/prettier-plugin-sort-imports" - ], - "importOrder": [ - "^react", - "", - "^@/", - "^[./]" - ], + "plugins": ["@ianvs/prettier-plugin-sort-imports"], + "importOrder": ["^react", "", "^@/", "^[./]"], "importOrderSortSpecifiers": true -} \ No newline at end of file +} diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts index 6a574b3ea..7275fd178 100644 --- a/frontend/.storybook/preview.ts +++ b/frontend/.storybook/preview.ts @@ -1,5 +1,5 @@ -import type { Preview } from '@storybook/react'; import { useEffect } from 'react'; +import type { Preview } from '@storybook/react'; const preview: Preview = { decorators: [ diff --git a/frontend/config/vite.config.ts b/frontend/config/vite.config.ts index cb2b190d6..231b4f088 100644 --- a/frontend/config/vite.config.ts +++ b/frontend/config/vite.config.ts @@ -1,55 +1,53 @@ import react from '@vitejs/plugin-react'; +import { visualizer } from 'rollup-plugin-visualizer'; import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; -import { visualizer } from 'rollup-plugin-visualizer'; const DEFAULT_PORT = 3000; export default defineConfig({ - plugins: [ - react(), - tsconfigPaths(), - ], + plugins: [react(), tsconfigPaths()], build: { rollupOptions: { output: { manualChunks(id) { - if (!id.includes("node_modules")) return; + if (!id.includes('node_modules')) return; - if (id.includes("react-router")) return "router"; - if (id.includes("react-datepicker")) return "dates"; + if (id.includes('react-router')) return 'router'; + if (id.includes('react-datepicker')) return 'dates'; if ( - id.includes("react-markdown") || - id.includes("remark") || - id.includes("rehype") || - id.includes("unified") || - id.includes("micromark") || - id.includes("mdast") || - id.includes("hast") || - id.includes("parse5") + id.includes('react-markdown') || + id.includes('remark') || + id.includes('rehype') || + id.includes('unified') || + id.includes('micromark') || + id.includes('mdast') || + id.includes('hast') || + id.includes('parse5') ) { - return "markdown"; + return 'markdown'; } if ( - id.includes("node_modules/react/") || - id.includes("node_modules/react-dom/") || - id.includes("scheduler") + id.includes('node_modules/react/') || + id.includes('node_modules/react-dom/') || + id.includes('scheduler') ) { - return "react-vendor"; + return 'react-vendor'; } - if (id.includes("zustand")) return "state"; - if (id.includes("@tanstack/react-query")) return "react-query"; + if (id.includes('zustand')) return 'state'; + if (id.includes('@tanstack/react-query')) return 'react-query'; - if (id.includes("mixpanel-browser")) return "analytics"; - if (id.includes("@sentry")) return "sentry"; + if (id.includes('mixpanel-browser')) return 'analytics'; + if (id.includes('@sentry')) return 'sentry'; - if (id.includes("framer-motion") || id.includes("motion-dom")) return "motion"; - if (id.includes("swiper")) return "swiper"; - if (id.includes("date-fns")) return "dates"; + if (id.includes('framer-motion') || id.includes('motion-dom')) + return 'motion'; + if (id.includes('swiper')) return 'swiper'; + if (id.includes('date-fns')) return 'dates'; - return "vendor"; + return 'vendor'; }, }, }, diff --git a/frontend/netlify.toml b/frontend/netlify.toml deleted file mode 100644 index 67560159c..000000000 --- a/frontend/netlify.toml +++ /dev/null @@ -1,4 +0,0 @@ -[[redirects]] -from = "/*" -to = "/index.html" -status = 200 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dca50f26f..9feac9a3d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from 'styled-components'; import { ScrollToTopButton } from '@/components/common/ScrollToTopButton/ScrollToTopButton'; import { AdminClubProvider } from '@/context/AdminClubContext'; -import { ScrollToTop } from '@/hooks/ScrollToTop'; +import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop'; import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; import ClubDetailPage from '@/pages/ClubDetailPage/ClubDetailPage'; @@ -16,7 +16,17 @@ import ClubUnionPage from './pages/ClubUnionPage/ClubUnionPage'; import IntroducePage from './pages/IntroducePage/IntroducePage'; import 'swiper/css'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, +}); const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes')); diff --git a/frontend/src/apis/applicants.ts b/frontend/src/apis/applicants.ts new file mode 100644 index 000000000..e2be4eca5 --- /dev/null +++ b/frontend/src/apis/applicants.ts @@ -0,0 +1,31 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse, withErrorHandling } from './utils/apiHelpers'; + +export const getClubApplicants = async (applicationFormId: string) => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/apply/info/${applicationFormId}`, + ); + return handleResponse(response, '지원자 목록을 불러오는데 실패했습니다.'); + }, 'Error fetching club applicants'); +}; + +export const deleteApplicants = async ( + applicantIds: string[], + applicationFormId: string, +) => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}`, + { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ applicantIds: applicantIds }), + }, + ); + return handleResponse(response, '지원자 삭제에 실패했습니다.'); + }, 'Error fetching delete applicants'); +}; diff --git a/frontend/src/apis/applicants/deleteApplicants.ts b/frontend/src/apis/applicants/deleteApplicants.ts deleted file mode 100644 index 3f4d1f878..000000000 --- a/frontend/src/apis/applicants/deleteApplicants.ts +++ /dev/null @@ -1,32 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { secureFetch } from '../auth/secureFetch'; - -const deleteApplicants = async ( - applicantIds: string[], - applicationFormId: string, -) => { - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/applicant/${applicationFormId}`, - { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ applicantIds: applicantIds }), - }, - ); - if (!response.ok) { - console.error(`Failed to fetch: ${response.statusText}`); - throw new Error((await response.json()).message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('Error fetching delete applicants', error); - throw error; - } -}; - -export default deleteApplicants; diff --git a/frontend/src/apis/applicants/getClubApplicants.ts b/frontend/src/apis/applicants/getClubApplicants.ts deleted file mode 100644 index 2b7ded337..000000000 --- a/frontend/src/apis/applicants/getClubApplicants.ts +++ /dev/null @@ -1,22 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { secureFetch } from '../auth/secureFetch'; - -const getClubApplicants = async (applicationFormId: string) => { - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/apply/info/${applicationFormId}`, - ); - if (!response.ok) { - console.error(`Failed to fetch: ${response.statusText}`); - throw new Error((await response.json()).message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('Error fetching club applicants', error); - throw error; - } -}; - -export default getClubApplicants; diff --git a/frontend/src/apis/application.ts b/frontend/src/apis/application.ts new file mode 100644 index 000000000..5ba52917a --- /dev/null +++ b/frontend/src/apis/application.ts @@ -0,0 +1,171 @@ +import API_BASE_URL from '@/constants/api'; +import { UpdateApplicantParams } from '@/types/applicants'; +import { AnswerItem, ApplicationFormData } from '@/types/application'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse, withErrorHandling } from './utils/apiHelpers'; + +export const applyToClub = async ( + clubId: string, + applicationFormId: string, + answers: AnswerItem[], +) => { + return withErrorHandling(async () => { + const response = await fetch( + `${API_BASE_URL}/api/club/${clubId}/apply/${applicationFormId}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + questions: [...answers], + }), + }, + ); + return handleResponse(response, '답변 제출에 실패했습니다.'); + }, '답변 제출 중 오류 발생:'); +}; + +export const createApplication = async (data: ApplicationFormData) => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/api/club/application`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + return handleResponse(response, '지원서 제출에 실패했습니다.'); + }, '지원서 제출 중 오류 발생:'); +}; + +export const deleteApplication = async (applicationFormId: string) => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/application/${applicationFormId}`, + { + method: 'DELETE', + }, + ); + return handleResponse(response); + }, 'Error fetching delete application'); +}; + +export const duplicateApplication = async (applicationFormId: string) => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/application/${applicationFormId}/duplicate`, + { + method: 'POST', + }, + ); + return handleResponse(response, '지원서 복제에 실패했습니다.'); + }, '지원서 복제 중 오류 발생:'); +}; + +export const getActiveApplications = async (clubId: string) => { + return withErrorHandling(async () => { + const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); + return handleResponse(response); + }, '활성화된 지원서 목록 조회 중 오류 발생:'); +}; + +export const getAllApplicationForms = async () => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/api/club/application`); + return handleResponse(response); + }, '모든 지원서 양식 조회 중 오류 발생:'); +}; + +export const getApplication = async ( + clubId: string, + applicationFormId: string, +): Promise => { + return withErrorHandling(async () => { + const response = await fetch( + `${API_BASE_URL}/api/club/${clubId}/apply/${applicationFormId}`, + ); + return handleResponse(response); + }, '지원서 조회 중 오류가 발생했습니다'); +}; + +export const getApplicationOptions = async (clubId: string) => { + return withErrorHandling(async () => { + const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); + const data = await handleResponse(response); + + let forms: Array<{ id: string; title: string }> = []; + if (data && Array.isArray(data.forms)) { + forms = data.forms; + } + return forms; + }, '지원서 옵션 조회 중 오류가 발생했습니다.'); +}; + +export const updateApplicantDetail = async ( + applicant: UpdateApplicantParams[], + applicationFormId: string | undefined, +) => { + if (!applicationFormId) { + throw new Error( + 'applicationFormId가 존재하지 않아 지원자 정보를 수정할 수 없습니다.', + ); + } + + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(applicant), + }, + ); + return handleResponse( + response, + '지원자의 지원서 정보 수정에 실패했습니다.', + ); + }, '지원자의 지원서 정보 수정 중 오류 발생:'); +}; + +export const updateApplication = async ( + data: ApplicationFormData, + applicationFormId: string, +) => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/application/${applicationFormId}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }, + ); + return handleResponse(response, '지원서 수정에 실패했습니다.'); + }, '지원서 수정 중 오류 발생:'); +}; + +export const updateApplicationStatus = async ( + applicationFormId: string, + currentStatus: string, +) => { + const newStatus = currentStatus === 'ACTIVE' ? false : true; + + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/application/${applicationFormId}`, + { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ active: newStatus }), + }, + ); + return handleResponse(response, '지원서 상태 수정에 실패했습니다.'); + }, '지원서 상태 수정 중 오류 발생:'); +}; diff --git a/frontend/src/apis/application/applyToClub.ts b/frontend/src/apis/application/applyToClub.ts deleted file mode 100644 index 99ed63e85..000000000 --- a/frontend/src/apis/application/applyToClub.ts +++ /dev/null @@ -1,35 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { AnswerItem } from '@/types/application'; - -export const applyToClub = async ( - clubId: string, - applicationFormId: string, - answers: AnswerItem[], -) => { - try { - const response = await fetch( - `${API_BASE_URL}/api/club/${clubId}/apply/${applicationFormId}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - questions: [...answers], - }), - }, - ); - - if (!response.ok) { - throw new Error('답변 제출에 실패했습니다.'); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('답변 제출 중 오류 발생:', error); - throw error; - } -}; - -export default applyToClub; diff --git a/frontend/src/apis/application/createApplication.ts b/frontend/src/apis/application/createApplication.ts deleted file mode 100644 index e307d84e9..000000000 --- a/frontend/src/apis/application/createApplication.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; -import { ApplicationFormData } from '@/types/application'; - -export const createApplication = async (data: ApplicationFormData) => { - try { - const response = await secureFetch(`${API_BASE_URL}/api/club/application`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error('지원서 제출에 실패했습니다.'); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('지원서 제출 중 오류 발생:', error); - throw error; - } -}; - -export default createApplication; diff --git a/frontend/src/apis/application/deleteApplication.ts b/frontend/src/apis/application/deleteApplication.ts deleted file mode 100644 index 906b47d98..000000000 --- a/frontend/src/apis/application/deleteApplication.ts +++ /dev/null @@ -1,25 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { secureFetch } from '../auth/secureFetch'; - -const deleteApplication = async (applicationFormId: string) => { - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/application/${applicationFormId}`, - { - method: 'DELETE', - }, - ); - if (!response.ok) { - console.error(`Failed to delete: ${response.statusText}`); - throw new Error((await response.json()).message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('Error fetching delete application', error); - throw error; - } -}; - -export default deleteApplication; diff --git a/frontend/src/apis/application/duplicateApplication.ts b/frontend/src/apis/application/duplicateApplication.ts deleted file mode 100644 index 9ea9f00be..000000000 --- a/frontend/src/apis/application/duplicateApplication.ts +++ /dev/null @@ -1,22 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { secureFetch } from '../auth/secureFetch'; - -export const duplicateApplication = async (applicationFormId: string) => { - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/application/${applicationFormId}/duplicate`, - { - method: 'POST', - }, - ); - if (!response.ok) { - throw new Error('지원서 복제에 실패했습니다.'); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('지원서 복제 중 오류 발생:', error); - throw error; - } -}; diff --git a/frontend/src/apis/application/getActiveApplications.ts b/frontend/src/apis/application/getActiveApplications.ts deleted file mode 100644 index a3157c88c..000000000 --- a/frontend/src/apis/application/getActiveApplications.ts +++ /dev/null @@ -1,20 +0,0 @@ -import API_BASE_URL from '@/constants/api'; - -const getActiveApplications = async (clubId: string) => { - try { - const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); - - if (!response.ok) { - console.error(`Failed to fetch: ${response.statusText}`); - throw new Error((await response.json()).message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('활성화된 지원서 목록 조회 중 오류 발생:', error); - throw error; - } -}; - -export default getActiveApplications; diff --git a/frontend/src/apis/application/getAllApplications.ts b/frontend/src/apis/application/getAllApplications.ts deleted file mode 100644 index 8abd2039e..000000000 --- a/frontend/src/apis/application/getAllApplications.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; - -const getAllApplicationForms = async () => { - try { - const response = await secureFetch(`${API_BASE_URL}/api/club/application`); - - if (!response.ok) { - console.error(`Failed to fetch: ${response.statusText}`); - throw new Error((await response.json()).message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('모든 지원서 양식 조회 중 오류 발생:', error); - throw error; - } -}; - -export default getAllApplicationForms; diff --git a/frontend/src/apis/application/getApplication.ts b/frontend/src/apis/application/getApplication.ts deleted file mode 100644 index e7099912b..000000000 --- a/frontend/src/apis/application/getApplication.ts +++ /dev/null @@ -1,32 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { ApplicationFormData } from '@/types/application'; - -const getApplication = async ( - clubId: string, - applicationFormId: string, -): Promise => { - try { - const response = await fetch( - `${API_BASE_URL}/api/club/${clubId}/apply/${applicationFormId}`, - ); - if (!response.ok) { - let message = response.statusText; - try { - const errorData = await response.json(); - if (errorData?.message) message = errorData.message; - } catch {} - console.error(`Failed to fetch: ${message}`); - throw new Error(message); - } - - const result = await response.json(); - return result.data; - } catch (error) { - // [x] FIXME: - // {"statuscode":"800-1","message":"지원서가 존재하지 않습니다.","data":null} - console.error('지원서 조회 중 오류가 발생했습니다', error); - throw error; - } -}; - -export default getApplication; diff --git a/frontend/src/apis/application/getApplicationOptions.ts b/frontend/src/apis/application/getApplicationOptions.ts deleted file mode 100644 index 2b6027165..000000000 --- a/frontend/src/apis/application/getApplicationOptions.ts +++ /dev/null @@ -1,28 +0,0 @@ -import API_BASE_URL from '@/constants/api'; - -const getApplicationOptions = async (clubId: string) => { - try { - const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); - if (!response.ok) { - let message = response.statusText; - try { - const errorData = await response.json(); - if (errorData?.message) message = errorData.message; - } catch {} - console.error(`Failed to fetch options: ${message}`); - throw new Error(message); - } - - const result = await response.json(); - let forms: Array<{ id: string; title: string }> = []; - if (result && result.data && Array.isArray(result.data.forms)) { - forms = result.data.forms; - } - return forms; - } catch (error) { - console.error('지원서 옵션 조회 중 오류가 발생했습니다.', error); - throw error; - } -}; - -export default getApplicationOptions; diff --git a/frontend/src/apis/application/updateApplicantDetail.ts b/frontend/src/apis/application/updateApplicantDetail.ts deleted file mode 100644 index 7e6e0dee4..000000000 --- a/frontend/src/apis/application/updateApplicantDetail.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; -import { UpdateApplicantParams } from '@/types/applicants'; - -export const updateApplicantDetail = async ( - applicant: UpdateApplicantParams[], - applicationFormId: string | undefined, -) => { - if (!applicationFormId) { - throw new Error( - 'applicationFormId가 존재하지 않아 지원자 정보를 수정할 수 없습니다.', - ); - } - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/applicant/${applicationFormId}`, - { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(applicant), - }, - ); - - if (!response.ok) { - throw new Error('지원자의 지원서 정보 수정에 실패했습니다.'); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('지원자의 지원서 정보 수정 중 오류 발생:', error); - throw error; - } -}; - -export default updateApplicantDetail; diff --git a/frontend/src/apis/application/updateApplication.ts b/frontend/src/apis/application/updateApplication.ts deleted file mode 100644 index 6a44b56ad..000000000 --- a/frontend/src/apis/application/updateApplication.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; -import { ApplicationFormData } from '@/types/application'; - -export const updateApplication = async ( - data: ApplicationFormData, - applicationFormId: string, -) => { - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/application/${applicationFormId}`, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }, - ); - - if (!response.ok) { - throw new Error('지원서 수정에 실패했습니다.'); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error('지원서 수정 중 오류 발생:', error); - throw error; - } -}; - -export const updateApplicationStatus = async ( - applicationFormId: string, - currentStatus: string, -) => { - const newStatus = currentStatus === 'ACTIVE' ? false : true; - try { - const response = await secureFetch( - `${API_BASE_URL}/api/club/application/${applicationFormId}`, - { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ active: newStatus }), - }, - ); - if (!response.ok) { - throw new Error('지원서 상태 수정에 실패했습니다.'); - } - const result = await response.json(); - return result.data; - } catch (error) { - console.error('지원서 상태 수정 중 오류 발생:', error); - throw error; - } -}; diff --git a/frontend/src/apis/auth.ts b/frontend/src/apis/auth.ts new file mode 100644 index 000000000..0b78c0275 --- /dev/null +++ b/frontend/src/apis/auth.ts @@ -0,0 +1,74 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse, withErrorHandling } from './utils/apiHelpers'; + +interface LoginResponseData { + accessToken: string; + clubId: string; +} + +interface ChangePasswordPayload { + password: string; +} + +export const login = async ( + userId: string, + password: string, +): Promise => { + return withErrorHandling(async () => { + const response = await fetch(`${API_BASE_URL}/auth/user/login`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ userId, password }), + }); + return handleResponse(response, '로그인에 실패하였습니다.'); + }, '로그인 중 오류 발생'); +}; + +export const logout = async (): Promise => { + return withErrorHandling(async () => { + const accessToken = localStorage.getItem('accessToken'); + + if (!accessToken) { + return; + } + + const response = await fetch(`${API_BASE_URL}/auth/user/logout`, { + method: 'GET', + credentials: 'include', + }); + + await handleResponse(response, '로그아웃에 실패하였습니다.'); + }, '로그아웃 중 오류 발생'); +}; + +export const getClubIdByToken = async (): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/auth/user/find/club`, { + method: 'POST', + }); + const data = await handleResponse(response, '인증에 실패했습니다.'); + if (!data?.clubId) { + throw new Error('ClubId를 가져올 수 없습니다.'); + } + return data.clubId; + }, 'ClubId 조회 중 오류 발생'); +}; + +export const changePassword = async ( + payload: ChangePasswordPayload, +): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/auth/user/`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + await handleResponse(response, '비밀번호 변경에 실패했습니다.'); + }, '비밀번호 변경 중 오류 발생'); +}; diff --git a/frontend/src/apis/auth/changePassword.ts b/frontend/src/apis/auth/changePassword.ts deleted file mode 100644 index 93778e77c..000000000 --- a/frontend/src/apis/auth/changePassword.ts +++ /dev/null @@ -1,23 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { secureFetch } from './secureFetch'; - -interface ChangePasswordPayload { - password: string; -} - -export const changePassword = async ( - payload: ChangePasswordPayload, -): Promise => { - const response = await secureFetch(`${API_BASE_URL}/auth/user/`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || '비밀번호 변경에 실패했습니다.'); - } -}; diff --git a/frontend/src/apis/auth/getClubIdByToken.ts b/frontend/src/apis/auth/getClubIdByToken.ts deleted file mode 100644 index 4bb8cfdb0..000000000 --- a/frontend/src/apis/auth/getClubIdByToken.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; - -export const getClubIdByToken = async (): Promise => { - const response = await secureFetch(`${API_BASE_URL}/auth/user/find/club`, { - method: 'POST', - }); - - if (!response.ok) throw new Error('Unauthorized'); - - const { data } = await response.json(); - return data.clubId; -}; diff --git a/frontend/src/apis/auth/login.ts b/frontend/src/apis/auth/login.ts deleted file mode 100644 index 2bfbe73c6..000000000 --- a/frontend/src/apis/auth/login.ts +++ /dev/null @@ -1,34 +0,0 @@ -import API_BASE_URL from '@/constants/api'; - -interface LoginResponseData { - accessToken: string; - clubId: string; -} - -interface LoginResponse { - statuscode: string; - message: string; - data: LoginResponseData; -} - -export const login = async ( - userId: string, - password: string, -): Promise => { - const response = await fetch(`${API_BASE_URL}/auth/user/login`, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userId, password }), - }); - - if (!response.ok) { - throw new Error(`로그인에 실패하였습니다: ${response.statusText}`); - } - - const jsonResponse: LoginResponse = await response.json(); - - return jsonResponse.data; -}; diff --git a/frontend/src/apis/auth/logout.ts b/frontend/src/apis/auth/logout.ts deleted file mode 100644 index a4eae24df..000000000 --- a/frontend/src/apis/auth/logout.ts +++ /dev/null @@ -1,18 +0,0 @@ -import API_BASE_URL from '@/constants/api'; - -export const logout = async (): Promise => { - const accessToken = localStorage.getItem('accessToken'); - - if (!accessToken) { - return; - } - - const response = await fetch(`${API_BASE_URL}/auth/user/logout`, { - method: 'GET', - credentials: 'include', - }); - - if (!response.ok) { - throw new Error(`로그아웃에 실패하였습니다 ${response.statusText}`); - } -}; diff --git a/frontend/src/apis/auth/refreshAccessToken.ts b/frontend/src/apis/auth/refreshAccessToken.ts index f5830ccb8..97f054e50 100644 --- a/frontend/src/apis/auth/refreshAccessToken.ts +++ b/frontend/src/apis/auth/refreshAccessToken.ts @@ -6,12 +6,10 @@ export const refreshAccessToken = async (): Promise => { credentials: 'include', }); - // refresh 성공하여 accessToken 반환 if (res.status === 200) { const { data } = await res.json(); return data.accessToken; } - // refresh 실패 or 만료 throw new Error('REFRESH_FAILED'); }; diff --git a/frontend/src/apis/club.ts b/frontend/src/apis/club.ts new file mode 100644 index 000000000..216ba6937 --- /dev/null +++ b/frontend/src/apis/club.ts @@ -0,0 +1,81 @@ +import API_BASE_URL from '@/constants/api'; +import { ClubDescription, ClubDetail } from '@/types/club'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse, withErrorHandling } from './utils/apiHelpers'; + +export const getClubDetail = async (clubId: string): Promise => { + return withErrorHandling(async () => { + const response = await fetch(`${API_BASE_URL}/api/club/${clubId}`); + const data = await handleResponse( + response, + '클럽 정보를 불러오는데 실패했습니다.', + ); + if (!data?.club) { + throw new Error('클럽 정보를 가져올 수 없습니다.'); + } + return data.club; + }, 'Error fetching club details'); +}; + +export const getClubList = async ( + keyword: string = '', + recruitmentStatus: string = 'all', + category: string = 'all', + division: string = 'all', +) => { + return withErrorHandling(async () => { + const url = new URL(`${API_BASE_URL}/api/club/search/`); + const params = new URLSearchParams({ + keyword, + recruitmentStatus, + category, + division, + }); + + url.search = params.toString(); + const response = await fetch(url); + const data = await handleResponse( + response, + '클럽 데이터를 불러오는데 실패했습니다.', + ); + + if (!data) { + throw new Error('클럽 데이터를 가져올 수 없습니다.'); + } + + return { + clubs: data.clubs || [], + totalCount: data.totalCount || 0, + }; + }, '클럽 데이터를 불러오는데 실패했습니다'); +}; + +export const updateClubDescription = async ( + updatedData: ClubDescription, +): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/api/club/description`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedData), + }); + await handleResponse(response, '클럽 설명 수정에 실패했습니다.'); + }, 'Failed to update club description'); +}; + +export const updateClubDetail = async ( + updatedData: Partial, +): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch(`${API_BASE_URL}/api/club/info`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(updatedData), + }); + await handleResponse(response, '클럽 정보 수정에 실패했습니다.'); + }, 'Failed to update club detail'); +}; diff --git a/frontend/src/apis/getClubDetail.ts b/frontend/src/apis/getClubDetail.ts deleted file mode 100644 index c65d9f8f6..000000000 --- a/frontend/src/apis/getClubDetail.ts +++ /dev/null @@ -1,17 +0,0 @@ -import API_BASE_URL from '@/constants/api'; -import { ClubDetail } from '@/types/club'; - -export const getClubDetail = async (clubId: string): Promise => { - try { - const response = await fetch(`${API_BASE_URL}/api/club/${clubId}`); - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.statusText}`); - } - - const result = await response.json(); - return result.data.club; - } catch (error) { - console.error('Error fetching club details', error); - throw error; - } -}; diff --git a/frontend/src/apis/getClubList.ts b/frontend/src/apis/getClubList.ts deleted file mode 100644 index 4af81d240..000000000 --- a/frontend/src/apis/getClubList.ts +++ /dev/null @@ -1,30 +0,0 @@ -import API_BASE_URL from '@/constants/api'; - -export const getClubList = async ( - keyword: string = '', - recruitmentStatus: string = 'all', - category: string = 'all', - division: string = 'all', -) => { - const url = new URL(`${API_BASE_URL}/api/club/search/`); - const params = new URLSearchParams({ - keyword, - recruitmentStatus, - category, - division, - }); - - url.search = params.toString(); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error('클럽 데이터를 불러오는데 실패했습니다'); - } - - const result = await response.json(); - return { - clubs: result.data.clubs, - totalCount: result.data.totalCount, - }; -}; diff --git a/frontend/src/apis/image.ts b/frontend/src/apis/image.ts new file mode 100644 index 000000000..46b1d9f9b --- /dev/null +++ b/frontend/src/apis/image.ts @@ -0,0 +1,171 @@ +import API_BASE_URL from '@/constants/api'; +import { secureFetch } from './auth/secureFetch'; +import { handleResponse, withErrorHandling } from './utils/apiHelpers'; + +interface PresignedData { + presignedUrl: string; + finalUrl: string; +} + +interface FeedUploadRequest { + fileName: string; + contentType: string; +} + +// Storage 업로드 +export async function uploadToStorage( + presignedUrl: string, + file: File, +): Promise { + return withErrorHandling(async () => { + const response = await fetch(presignedUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type }, + }); + await handleResponse(response, `S3 업로드 실패 : ${response.status}`); + }, 'S3 업로드 중 오류 발생'); +} + +// Cover API +export const coverApi = { + getUploadUrl: async ( + clubId: string, + fileName: string, + contentType: string, + ): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/cover/upload-url`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName, contentType }), + }, + ); + return handleResponse( + response, + `커버 업로드 URL 생성 실패 : ${response.status}`, + ); + }, '커버 업로드 URL 생성 중 오류 발생'); + }, + + completeUpload: async (clubId: string, fileUrl: string): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/cover/complete`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileUrl }), + }, + ); + await handleResponse( + response, + `커버 업로드 완료 처리 실패 : ${response.status}`, + ); + }, '커버 업로드 완료 처리 중 오류 발생'); + }, + + delete: async (clubId: string): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/cover`, + { + method: 'DELETE', + }, + ); + await handleResponse(response, `커버 삭제 실패: ${response.status}`); + }, '커버 삭제 중 오류 발생'); + }, +}; + +// Feed API +export const feedApi = { + getUploadUrls: async ( + clubId: string, + uploadRequests: FeedUploadRequest[], + ): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/feed/upload-url`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(uploadRequests), + }, + ); + return handleResponse( + response, + `피드 업로드 URL 생성 실패 : ${response.status}`, + ); + }, '피드 업로드 URL 생성 중 오류 발생'); + }, + + updateFeeds: async (clubId: string, feedUrls: string[]): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/feeds`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ feeds: feedUrls }), + }, + ); + await handleResponse(response, `피드 업데이트 실패 : ${response.status}`); + }, '피드 업데이트 중 오류 발생'); + }, +}; + +// Logo API +export const logoApi = { + getUploadUrl: async ( + clubId: string, + fileName: string, + contentType: string, + ): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/logo/upload-url`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName, contentType }), + }, + ); + return handleResponse( + response, + `업로드 URL 생성 실패 : ${response.status}`, + ); + }, '로고 업로드 URL 생성 중 오류 발생'); + }, + + completeUpload: async (clubId: string, fileUrl: string): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/logo/complete`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileUrl }), + }, + ); + await handleResponse( + response, + `업로드 완료 처리 실패 : ${response.status}`, + ); + }, '로고 업로드 완료 처리 중 오류 발생'); + }, + + delete: async (clubId: string): Promise => { + return withErrorHandling(async () => { + const response = await secureFetch( + `${API_BASE_URL}/api/club/${clubId}/logo`, + { + method: 'DELETE', + }, + ); + await handleResponse(response, `로고 삭제 실패 : ${response.status}`); + }, '로고 삭제 중 오류 발생'); + }, +}; diff --git a/frontend/src/apis/image/cover.ts b/frontend/src/apis/image/cover.ts deleted file mode 100644 index 194b24e9f..000000000 --- a/frontend/src/apis/image/cover.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; - -interface PresignedData { - presignedUrl: string; - finalUrl: string; -} - -export const coverApi = { - getUploadUrl: async ( - clubId: string, - fileName: string, - contentType: string, - ): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/cover/upload-url`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName, contentType }), - }, - ); - - if (!response.ok) { - throw new Error(`커버 업로드 URL 생성 실패 : ${response.status}`); - } - - const result = await response.json(); - return result.data; - }, - - completeUpload: async (clubId: string, fileUrl: string): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/cover/complete`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileUrl }), - }, - ); - - if (!response.ok) { - throw new Error(`커버 업로드 완료 처리 실패 : ${response.status}`); - } - }, - - delete: async (clubId: string): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/cover`, - { - method: 'DELETE', - }, - ); - - if (!response.ok) { - throw new Error(`커버 삭제 실패: ${response.status}`); - } - }, -}; diff --git a/frontend/src/apis/image/feed.ts b/frontend/src/apis/image/feed.ts deleted file mode 100644 index 4fa51f4c0..000000000 --- a/frontend/src/apis/image/feed.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; - -interface FeedUploadRequest { - fileName: string; - contentType: string; -} - -interface PresignedData { - presignedUrl: string; - finalUrl: string; -} - -export const feedApi = { - getUploadUrls: async ( - clubId: string, - uploadRequests: FeedUploadRequest[], - ): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/feed/upload-url`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(uploadRequests), - }, - ); - - if (!response.ok) { - throw new Error(`피드 업로드 URL 생성 실패 : ${response.status}`); - } - - const result = await response.json(); - return result.data; - }, - - updateFeeds: async (clubId: string, feedUrls: string[]): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/feeds`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ feeds: feedUrls }), - }, - ); - - if (!response.ok) { - throw new Error(`피드 업데이트 실패 : ${response.status}`); - } - }, -}; diff --git a/frontend/src/apis/image/logo.ts b/frontend/src/apis/image/logo.ts deleted file mode 100644 index f5dd9d776..000000000 --- a/frontend/src/apis/image/logo.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; - -interface PresignedData { - presignedUrl: string; - finalUrl: string; -} - -export const logoApi = { - getUploadUrl: async ( - clubId: string, - fileName: string, - contentType: string, - ): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/logo/upload-url`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileName, contentType }), - }, - ); - - if (!response.ok) { - throw new Error(`업로드 URL 생성 실패 : ${response.status}`); - } - - const result = await response.json(); - return result.data; - }, - - completeUpload: async (clubId: string, fileUrl: string): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/logo/complete`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileUrl }), - }, - ); - if (!response.ok) { - throw new Error(`업로드 완료 처리 실패 : ${response.status}`); - } - }, - - delete: async (clubId: string): Promise => { - const response = await secureFetch( - `${API_BASE_URL}/api/club/${clubId}/logo`, - { - method: 'DELETE', - }, - ); - - if (!response.ok) { - throw new Error(`로고 삭제 실패 : ${response.status}`); - } - }, -}; diff --git a/frontend/src/apis/image/uploadToStorage.ts b/frontend/src/apis/image/uploadToStorage.ts deleted file mode 100644 index 4d64091c6..000000000 --- a/frontend/src/apis/image/uploadToStorage.ts +++ /dev/null @@ -1,14 +0,0 @@ -export async function uploadToStorage( - presignedUrl: string, - file: File, -): Promise { - const response = await fetch(presignedUrl, { - method: 'PUT', - body: file, - headers: { 'Content-Type': file.type }, - }); - - if (!response.ok) { - throw new Error(`S3 업로드 실패 : ${response.status}`); - } -} diff --git a/frontend/src/apis/updateClubDescription.ts b/frontend/src/apis/updateClubDescription.ts deleted file mode 100644 index 893aad903..000000000 --- a/frontend/src/apis/updateClubDescription.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; -import { ClubDescription } from '@/types/club'; - -export const updateClubDescription = async ( - updatedData: ClubDescription, -): Promise => { - const response = await secureFetch(`${API_BASE_URL}/api/club/description`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedData), - }); - - if (!response.ok) { - let errorMessage = `Failed to update club (HTTP ${response.status})`; - - try { - const errorResult = await response.json(); - if (errorResult?.message) { - errorMessage += `: ${errorResult.message}`; - } - } catch (error) { - console.error('📌 오류 응답 JSON 파싱 실패:', error); - } - - throw new Error(errorMessage); - } - - try { - await response.json(); - } catch (error) { - console.error('📌 JSON 파싱 실패:', error); - throw new Error('Invalid JSON response from API'); - } -}; diff --git a/frontend/src/apis/updateClubDetail.ts b/frontend/src/apis/updateClubDetail.ts deleted file mode 100644 index 896ed8fd1..000000000 --- a/frontend/src/apis/updateClubDetail.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { secureFetch } from '@/apis/auth/secureFetch'; -import API_BASE_URL from '@/constants/api'; -import { ClubDetail } from '@/types/club'; - -export const updateClubDetail = async ( - updatedData: Partial, -): Promise => { - const response = await secureFetch(`${API_BASE_URL}/api/club/info`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updatedData), - }); - - let result; - try { - result = await response.json(); - } catch (error) { - console.error('📌 JSON 파싱 실패:', error); - result = null; - } - - if (!response.ok) { - const errorMessage = result?.message - ? `Failed to update club (HTTP ${response.status}): ${result.message}` - : `Failed to update club (HTTP ${response.status})`; - - throw new Error(errorMessage); - } - - if (!result?.data) { - console.error('📌 API 응답에 data 필드가 없음:', result); - throw new Error('Unexpected API response: Missing data field'); - } -}; diff --git a/frontend/src/apis/utils/apiHelpers.ts b/frontend/src/apis/utils/apiHelpers.ts new file mode 100644 index 000000000..b7e9f2980 --- /dev/null +++ b/frontend/src/apis/utils/apiHelpers.ts @@ -0,0 +1,51 @@ +export const handleResponse = async ( + response: Response, + customErrorMessage?: string, +) => { + if (!response.ok) { + if (customErrorMessage) { + throw new Error(customErrorMessage); + } + + let message = response.statusText; + try { + const errorData = await response.json(); + if (errorData?.message) { + message = errorData.message; + } + } catch { + // JSON 파싱 실패시 statusText 사용 + } + throw new Error(message); + } + + const contentType = response.headers.get('content-type'); + const contentLength = response.headers.get('content-length'); + + if (contentLength === '0' || !contentType?.includes('application/json')) { + return undefined; + } + const text = await response.text(); + if (!text) { + return undefined; + } + + try { + const result = JSON.parse(text); + return result.data; + } catch { + return undefined; + } +}; + +export const withErrorHandling = async ( + apiCall: () => Promise, + errorMessage: string, +): Promise => { + try { + return await apiCall(); + } catch (error) { + console.error(errorMessage, error); + throw error; + } +}; diff --git a/frontend/src/components/application/QuestionDescription/QuestionDescription.tsx b/frontend/src/components/application/QuestionDescription/QuestionDescription.tsx index 826b7c24d..10b706c01 100644 --- a/frontend/src/components/application/QuestionDescription/QuestionDescription.tsx +++ b/frontend/src/components/application/QuestionDescription/QuestionDescription.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react'; import styled from 'styled-components'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; interface QuestionDescriptionProps { description: string; diff --git a/frontend/src/components/application/QuestionTitle/QuestionTitle.tsx b/frontend/src/components/application/QuestionTitle/QuestionTitle.tsx index 3bb16ed99..9cee46f44 100644 --- a/frontend/src/components/application/QuestionTitle/QuestionTitle.tsx +++ b/frontend/src/components/application/QuestionTitle/QuestionTitle.tsx @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef } from 'react'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; import useDevice from '@/hooks/useDevice'; import * as Styled from './QuestionTitle.styles'; @@ -38,7 +38,9 @@ const QuestionTitle = ({ {mode === 'answer' ? ( {title} - {required && } + {required && ( + + )} ) : ( @@ -55,7 +57,9 @@ const QuestionTitle = ({ placeholder={APPLICATION_FORM.QUESTION_TITLE.placeholder} aria-required={required} /> - {required && } + {required && ( + + )} )} diff --git a/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts b/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts index d7ad5158c..5a4e412b9 100644 --- a/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts +++ b/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts @@ -1,5 +1,5 @@ -import { colors } from '@/styles/theme/colors'; import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; export const EmptyMessage = styled.div` padding: 16px 8px; diff --git a/frontend/src/components/application/modals/ApplicationSelectModal.tsx b/frontend/src/components/application/modals/ApplicationSelectModal.tsx index 3a1b05e13..75109cfa7 100644 --- a/frontend/src/components/application/modals/ApplicationSelectModal.tsx +++ b/frontend/src/components/application/modals/ApplicationSelectModal.tsx @@ -1,7 +1,7 @@ +import ModalLayout from '@/components/common/Modal/ModalLayout'; +import PortalModal from '@/components/common/Modal/PortalModal'; import { ApplicationForm } from '@/types/application'; import * as Styled from './ApplicationSelectModal.styles'; -import PortalModal from '@/components/common/Modal/PortalModal'; -import ModalLayout from '@/components/common/Modal/ModalLayout'; export interface ApplicationSelectModalProps { isOpen: boolean; @@ -15,15 +15,13 @@ interface ApplicationOptionsProps { onOptionSelect: (application: ApplicationForm) => void; } -const ApplicationOptions = ({ - applicationOptions, +const ApplicationOptions = ({ + applicationOptions, onOptionSelect, - }: ApplicationOptionsProps) => { +}: ApplicationOptionsProps) => { if (applicationOptions.length === 0) { return ( - - 지원 가능한 분야가 없습니다. - + 지원 가능한 분야가 없습니다. ); } @@ -32,7 +30,9 @@ const ApplicationOptions = ({ {applicationOptions.map((application) => ( {onOptionSelect(application)}} + onClick={() => { + onOptionSelect(application); + }} > {application.title} @@ -48,15 +48,12 @@ const ApplicationSelectModal = ({ onOptionSelect, }: ApplicationSelectModalProps) => { return ( - - - + + + ); diff --git a/frontend/src/components/application/questionTypes/Choice.tsx b/frontend/src/components/application/questionTypes/Choice.tsx index c27e4b6bd..7f4245846 100644 --- a/frontend/src/components/application/questionTypes/Choice.tsx +++ b/frontend/src/components/application/questionTypes/Choice.tsx @@ -2,7 +2,7 @@ import DeleteIcon from '@/assets/images/icons/delete_choice.svg'; import QuestionDescription from '@/components/application/QuestionDescription/QuestionDescription'; import QuestionTitle from '@/components/application/QuestionTitle/QuestionTitle'; import InputField from '@/components/common/InputField/InputField'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; import ChoiceItem from '@/pages/ApplicationFormPage/components/ChoiceItem/ChoiceItem'; import { ChoiceProps } from '@/types/application'; import * as Styled from './Choice.styles'; diff --git a/frontend/src/components/application/questionTypes/LongText.tsx b/frontend/src/components/application/questionTypes/LongText.tsx index c687d649d..2f7f07e79 100644 --- a/frontend/src/components/application/questionTypes/LongText.tsx +++ b/frontend/src/components/application/questionTypes/LongText.tsx @@ -1,7 +1,7 @@ import QuestionDescription from '@/components/application/QuestionDescription/QuestionDescription'; import QuestionTitle from '@/components/application/QuestionTitle/QuestionTitle'; import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; import { TextProps } from '@/types/application'; const LongText = ({ diff --git a/frontend/src/components/application/questionTypes/ShortText.tsx b/frontend/src/components/application/questionTypes/ShortText.tsx index c2c6e8077..3702da555 100644 --- a/frontend/src/components/application/questionTypes/ShortText.tsx +++ b/frontend/src/components/application/questionTypes/ShortText.tsx @@ -1,7 +1,7 @@ import QuestionDescription from '@/components/application/QuestionDescription/QuestionDescription'; import QuestionTitle from '@/components/application/QuestionTitle/QuestionTitle'; import InputField from '@/components/common/InputField/InputField'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; import { TextProps } from '@/types/application'; const ShortText = ({ @@ -15,7 +15,6 @@ const ShortText = ({ onTitleChange, onDescriptionChange, }: TextProps) => { - return ( <> ; export default meta; @@ -50,59 +50,60 @@ const StyledTrigger = styled.button` `; const OPTIONS = [ - { label: '옵션 1', value: 'option1' }, - { label: '옵션 2', value: 'option2' }, - { label: '옵션 3', value: 'option3' }, + { label: '옵션 1', value: 'option1' }, + { label: '옵션 2', value: 'option2' }, + { label: '옵션 3', value: 'option3' }, ]; export const Default: Story = { - args: { - open: false, - selected: 'option1', - options: OPTIONS, - onToggle: () => { }, - onSelect: () => { }, - children: null, - }, - render: (args) => { - const [isOpen, setIsOpen] = useState(args.open); - const [selected, setSelected] = useState(args.selected); + args: { + open: false, + selected: 'option1', + options: OPTIONS, + onToggle: () => {}, + onSelect: () => {}, + children: null, + }, + render: (args) => { + const [isOpen, setIsOpen] = useState(args.open); + const [selected, setSelected] = useState(args.selected); - const handleSelect = (value: string) => { - setSelected(value); - args.onSelect(value); - }; + const handleSelect = (value: string) => { + setSelected(value); + args.onSelect(value); + }; - const onToggleWrapper = (currentOpenState: boolean) => { - setIsOpen(!currentOpenState); - args.onToggle(!currentOpenState); - } + const onToggleWrapper = (currentOpenState: boolean) => { + setIsOpen(!currentOpenState); + args.onToggle(!currentOpenState); + }; - const selectedLabel = OPTIONS.find(opt => opt.value === selected)?.label || '선택하세요'; + const selectedLabel = + OPTIONS.find((opt) => opt.value === selected)?.label || '선택하세요'; - return ( - - - - {selectedLabel} - - - - - {OPTIONS.map((option) => ( - - {option.label} - - ))} - - - ); - }, -}; \ No newline at end of file + return ( + + + + {selectedLabel} + + + + + {OPTIONS.map((option) => ( + + {option.label} + + ))} + + + ); + }, +}; diff --git a/frontend/src/components/common/CustomTextArea/CustomTextArea.stories.tsx b/frontend/src/components/common/CustomTextArea/CustomTextArea.stories.tsx index b96e3da87..fcbd1fe65 100644 --- a/frontend/src/components/common/CustomTextArea/CustomTextArea.stories.tsx +++ b/frontend/src/components/common/CustomTextArea/CustomTextArea.stories.tsx @@ -1,161 +1,161 @@ +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import CustomTextArea from './CustomTextArea'; -import { useState } from 'react'; const meta = { - title: 'Components/Common/CustomTextArea', - component: CustomTextArea, - parameters: { - layout: 'centered', + title: 'Components/Common/CustomTextArea', + component: CustomTextArea, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: '텍스트 영역의 값입니다.', + }, + onChange: { + action: 'changed', + description: '값이 변경될 때 호출되는 함수입니다.', + }, + placeholder: { + control: 'text', + description: '텍스트 영역의 플레이스홀더입니다.', + }, + label: { + control: 'text', + description: '텍스트 영역 상단에 표시되는 라벨입니다.', + }, + width: { + control: 'text', + description: '텍스트 영역의 너비입니다.', + }, + disabled: { + control: 'boolean', + description: '비활성화 여부입니다.', + }, + isError: { + control: 'boolean', + description: '에러 상태 여부입니다.', + }, + helperText: { + control: 'text', + description: '하단에 표시되는 도움말 텍스트입니다 (에러 시 표시).', + }, + showMaxChar: { + control: 'boolean', + description: '최대 글자수 표시 여부입니다.', }, - tags: ['autodocs'], - argTypes: { - value: { - control: 'text', - description: '텍스트 영역의 값입니다.', - }, - onChange: { - action: 'changed', - description: '값이 변경될 때 호출되는 함수입니다.', - }, - placeholder: { - control: 'text', - description: '텍스트 영역의 플레이스홀더입니다.', - }, - label: { - control: 'text', - description: '텍스트 영역 상단에 표시되는 라벨입니다.', - }, - width: { - control: 'text', - description: '텍스트 영역의 너비입니다.', - }, - disabled: { - control: 'boolean', - description: '비활성화 여부입니다.', - }, - isError: { - control: 'boolean', - description: '에러 상태 여부입니다.', - }, - helperText: { - control: 'text', - description: '하단에 표시되는 도움말 텍스트입니다 (에러 시 표시).', - }, - showMaxChar: { - control: 'boolean', - description: '최대 글자수 표시 여부입니다.', - }, - maxLength: { - control: 'number', - description: '최대 글자수 제한입니다.', - }, + maxLength: { + control: 'number', + description: '최대 글자수 제한입니다.', }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - placeholder: '내용을 입력하세요', - width: '300px', - value: '', - onChange: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - /> - ); - }, + args: { + placeholder: '내용을 입력하세요', + width: '300px', + value: '', + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + /> + ); + }, }; export const WithLabel: Story = { - args: { - label: '자기소개', - placeholder: '자기소개를 입력하세요', - width: '300px', - value: '', - onChange: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - /> - ); - }, + args: { + label: '자기소개', + placeholder: '자기소개를 입력하세요', + width: '300px', + value: '', + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + /> + ); + }, }; export const ErrorState: Story = { - args: { - label: '지원동기', - value: '너무 짧습니다.', - isError: true, - helperText: '10자 이상 입력해주세요.', - width: '300px', - onChange: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - /> - ); - }, + args: { + label: '지원동기', + value: '너무 짧습니다.', + isError: true, + helperText: '10자 이상 입력해주세요.', + width: '300px', + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + /> + ); + }, }; export const WithMaxLength: Story = { - args: { - label: '문의 내용', - placeholder: '100자 이내로 입력해주세요', - maxLength: 100, - showMaxChar: true, - width: '300px', - value: '', - onChange: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - /> - ); - }, + args: { + label: '문의 내용', + placeholder: '100자 이내로 입력해주세요', + maxLength: 100, + showMaxChar: true, + width: '300px', + value: '', + onChange: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + /> + ); + }, }; export const Disabled: Story = { - args: { - label: '피드백', - value: '이미 제출된 피드백입니다.', - disabled: true, - width: '300px', - onChange: () => { }, - }, + args: { + label: '피드백', + value: '이미 제출된 피드백입니다.', + disabled: true, + width: '300px', + onChange: () => {}, + }, }; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 0ef591969..4bf4c9c06 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -5,8 +5,8 @@ import DesktopMainIcon from '@/assets/images/moadong_name_logo.svg'; import AdminProfile from '@/components/common/Header/admin/AdminProfile'; import { USER_EVENT } from '@/constants/eventName'; import useHeaderNavigation from '@/hooks/Header/useHeaderNavigation'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import { useScrollDetection } from '@/hooks/useScrollDetection'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useScrollDetection } from '@/hooks/Scroll/useScrollDetection'; import SearchBox from '@/pages/MainPage/components/SearchBox/SearchBox'; import * as Styled from './Header.styles'; diff --git a/frontend/src/components/common/Header/admin/AdminProfile.tsx b/frontend/src/components/common/Header/admin/AdminProfile.tsx index 363c6a408..b6565be39 100644 --- a/frontend/src/components/common/Header/admin/AdminProfile.tsx +++ b/frontend/src/components/common/Header/admin/AdminProfile.tsx @@ -1,6 +1,6 @@ import DefaultMoadongLogo from '@/assets/images/logos/default_profile_image.svg'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import * as Styled from '../Header.styles'; const AdminProfile = () => { diff --git a/frontend/src/components/common/InputField/InputField.stories.tsx b/frontend/src/components/common/InputField/InputField.stories.tsx index 7059fd414..191148d5d 100644 --- a/frontend/src/components/common/InputField/InputField.stories.tsx +++ b/frontend/src/components/common/InputField/InputField.stories.tsx @@ -1,231 +1,231 @@ +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import InputField from './InputField'; -import { useState } from 'react'; const meta = { - title: 'Components/Common/InputField', - component: InputField, - parameters: { - layout: 'centered', + title: 'Components/Common/InputField', + component: InputField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: '입력 필드의 값입니다.', + }, + onChange: { + action: 'changed', + description: '값이 변경될 때 호출되는 함수입니다.', + }, + onClear: { + action: 'cleared', + description: '삭제 버튼 클릭 시 호출되는 함수입니다.', + }, + placeholder: { + control: 'text', + description: '입력 필드의 플레이스홀더입니다.', + }, + label: { + control: 'text', + description: '입력 필드 상단에 표시되는 라벨입니다.', + }, + type: { + control: 'radio', + options: ['text', 'password'], + description: '입력 필드의 타입입니다.', + }, + width: { + control: 'text', + description: '입력 필드의 너비입니다.', + }, + disabled: { + control: 'boolean', + description: '비활성화 여부입니다.', + }, + isError: { + control: 'boolean', + description: '에러 상태 여부입니다.', + }, + isSuccess: { + control: 'boolean', + description: '성공 상태 여부입니다.', + }, + helperText: { + control: 'text', + description: '하단에 표시되는 도움말 텍스트입니다 (에러 시 표시).', + }, + showClearButton: { + control: 'boolean', + description: '삭제 버튼 표시 여부입니다.', }, - tags: ['autodocs'], - argTypes: { - value: { - control: 'text', - description: '입력 필드의 값입니다.', - }, - onChange: { - action: 'changed', - description: '값이 변경될 때 호출되는 함수입니다.', - }, - onClear: { - action: 'cleared', - description: '삭제 버튼 클릭 시 호출되는 함수입니다.', - }, - placeholder: { - control: 'text', - description: '입력 필드의 플레이스홀더입니다.', - }, - label: { - control: 'text', - description: '입력 필드 상단에 표시되는 라벨입니다.', - }, - type: { - control: 'radio', - options: ['text', 'password'], - description: '입력 필드의 타입입니다.', - }, - width: { - control: 'text', - description: '입력 필드의 너비입니다.', - }, - disabled: { - control: 'boolean', - description: '비활성화 여부입니다.', - }, - isError: { - control: 'boolean', - description: '에러 상태 여부입니다.', - }, - isSuccess: { - control: 'boolean', - description: '성공 상태 여부입니다.', - }, - helperText: { - control: 'text', - description: '하단에 표시되는 도움말 텍스트입니다 (에러 시 표시).', - }, - showClearButton: { - control: 'boolean', - description: '삭제 버튼 표시 여부입니다.', - }, - showMaxChar: { - control: 'boolean', - description: '최대 글자수 표시 여부입니다.', - }, - maxLength: { - control: 'number', - description: '최대 글자수 제한입니다.', - }, - readOnly: { - control: 'boolean', - description: '읽기 전용 여부입니다.', - }, + showMaxChar: { + control: 'boolean', + description: '최대 글자수 표시 여부입니다.', }, + maxLength: { + control: 'number', + description: '최대 글자수 제한입니다.', + }, + readOnly: { + control: 'boolean', + description: '읽기 전용 여부입니다.', + }, + }, } satisfies Meta; export default meta; type Story = StoryObj; export const Default: Story = { - args: { - placeholder: '텍스트를 입력하세요', - width: '300px', - value: '', - onChange: () => { }, - onClear: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - onClear={() => { - setValue(''); - args.onClear?.(); - }} - /> - ); - }, + args: { + placeholder: '텍스트를 입력하세요', + width: '300px', + value: '', + onChange: () => {}, + onClear: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + onClear={() => { + setValue(''); + args.onClear?.(); + }} + /> + ); + }, }; export const WithLabel: Story = { - args: { - label: '이메일', - placeholder: 'example@email.com', - width: '300px', - value: '', - onChange: () => { }, - onClear: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - onClear={() => { - setValue(''); - args.onClear?.(); - }} - /> - ); - }, + args: { + label: '이메일', + placeholder: 'example@email.com', + width: '300px', + value: '', + onChange: () => {}, + onClear: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + onClear={() => { + setValue(''); + args.onClear?.(); + }} + /> + ); + }, }; export const Password: Story = { - args: { - type: 'password', - label: '비밀번호', - placeholder: '비밀번호를 입력하세요', - width: '300px', - value: '', - onChange: () => { }, - onClear: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - onClear={() => { - setValue(''); - args.onClear?.(); - }} - /> - ); - }, + args: { + type: 'password', + label: '비밀번호', + placeholder: '비밀번호를 입력하세요', + width: '300px', + value: '', + onChange: () => {}, + onClear: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + onClear={() => { + setValue(''); + args.onClear?.(); + }} + /> + ); + }, }; export const ErrorState: Story = { - args: { - label: '닉네임', - value: '이미 사용중인 닉네임입니다', - isError: true, - helperText: '이미 사용중인 닉네임입니다.', - width: '300px', - onChange: () => { }, - onClear: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - onClear={() => { - setValue(''); - args.onClear?.(); - }} - /> - ); - }, + args: { + label: '닉네임', + value: '이미 사용중인 닉네임입니다', + isError: true, + helperText: '이미 사용중인 닉네임입니다.', + width: '300px', + onChange: () => {}, + onClear: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + onClear={() => { + setValue(''); + args.onClear?.(); + }} + /> + ); + }, }; export const WithMaxLength: Story = { - args: { - label: '한줄 소개', - placeholder: '20자 이내로 입력해주세요', - maxLength: 20, - showMaxChar: true, - width: '300px', - value: '', - onChange: () => { }, - onClear: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value || ''); - return ( - { - setValue(e.target.value); - args.onChange?.(e); - }} - onClear={() => { - setValue(''); - args.onClear?.(); - }} - /> - ); - }, + args: { + label: '한줄 소개', + placeholder: '20자 이내로 입력해주세요', + maxLength: 20, + showMaxChar: true, + width: '300px', + value: '', + onChange: () => {}, + onClear: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value || ''); + return ( + { + setValue(e.target.value); + args.onChange?.(e); + }} + onClear={() => { + setValue(''); + args.onClear?.(); + }} + /> + ); + }, }; export const Disabled: Story = { - args: { - label: '아이디', - value: 'disabled_user', - disabled: true, - width: '300px', - onChange: () => { }, - onClear: () => { }, - }, + args: { + label: '아이디', + value: 'disabled_user', + disabled: true, + width: '300px', + onChange: () => {}, + onClear: () => {}, + }, }; diff --git a/frontend/src/components/common/InputField/InputField.styles.ts b/frontend/src/components/common/InputField/InputField.styles.ts index 7dbc653f1..794632e2e 100644 --- a/frontend/src/components/common/InputField/InputField.styles.ts +++ b/frontend/src/components/common/InputField/InputField.styles.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { colors } from '@/styles/theme/colors'; import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; export const InputContainer = styled.div<{ width: string; readOnly?: boolean }>` width: ${(props) => props.width}; diff --git a/frontend/src/components/common/Modal/Modal.styles.ts b/frontend/src/components/common/Modal/Modal.styles.ts index 3fccb6494..22a1486c2 100644 --- a/frontend/src/components/common/Modal/Modal.styles.ts +++ b/frontend/src/components/common/Modal/Modal.styles.ts @@ -1,6 +1,6 @@ import styled from 'styled-components'; -import { Z_INDEX } from '@/styles/zIndex'; import { colors } from '@/styles/theme/colors'; +import { Z_INDEX } from '@/styles/zIndex'; export const Overlay = styled.div` inset: 0; @@ -18,7 +18,7 @@ export const ContentWrapper = styled.div` outline: none; display: flex; justify-content: center; - width: 100%; + width: 100%; max-width: fit-content; `; @@ -30,7 +30,7 @@ export const StandardLayout = styled.div<{ $width?: string }>` box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22); display: flex; flex-direction: column; - width: ${({ $width }) => $width || '400px'}; + width: ${({ $width }) => $width || '400px'}; max-width: 100%; `; diff --git a/frontend/src/components/common/Modal/ModalLayout.stories.tsx b/frontend/src/components/common/Modal/ModalLayout.stories.tsx index 80569ab92..25b433b83 100644 --- a/frontend/src/components/common/Modal/ModalLayout.stories.tsx +++ b/frontend/src/components/common/Modal/ModalLayout.stories.tsx @@ -2,33 +2,33 @@ import type { Meta, StoryObj } from '@storybook/react'; import ModalLayout from './ModalLayout'; const meta = { - title: 'Components/Common/ModalLayout', - component: ModalLayout, - parameters: { - layout: 'centered', + title: 'Components/Common/ModalLayout', + component: ModalLayout, + parameters: { + layout: 'centered', + }, + argTypes: { + onClose: { + action: 'closed', + description: '모달 닫기 버튼 클릭 시 호출되는 함수입니다.', }, - argTypes: { - onClose: { - action: 'closed', - description: '모달 닫기 버튼 클릭 시 호출되는 함수입니다.', - }, - title: { - control: 'text', - description: '모달의 제목입니다.', - }, - description: { - control: 'text', - description: '모달의 설명 텍스트입니다.', - }, - children: { - control: 'text', - description: '모달 내부에 렌더링될 컨텐츠입니다.', - }, - width: { - control: 'text', - description: '모달의 너비를 설정합니다.', - }, + title: { + control: 'text', + description: '모달의 제목입니다.', }, + description: { + control: 'text', + description: '모달의 설명 텍스트입니다.', + }, + children: { + control: 'text', + description: '모달 내부에 렌더링될 컨텐츠입니다.', + }, + width: { + control: 'text', + description: '모달의 너비를 설정합니다.', + }, + }, } satisfies Meta; export default meta; @@ -36,64 +36,72 @@ type Story = StoryObj; // 기본 모달 스토리 export const Default: Story = { - args: { - title: '기본 모달 레이아웃', - description: '이것은 기본 모달 레이아웃입니다.', - children: '모달 내용이 여기에 들어갑니다.', - width: '400px', - }, + args: { + title: '기본 모달 레이아웃', + description: '이것은 기본 모달 레이아웃입니다.', + children: '모달 내용이 여기에 들어갑니다.', + width: '400px', + }, }; // 너비가 다른 모달 스토리 export const WideModal: Story = { - args: { - title: '넓은 모달 레이아웃', - description: '이것은 너비가 600px인 모달 레이아웃입니다.', - children: '모달 내용이 여기에 들어갑니다.', - width: '600px', - }, + args: { + title: '넓은 모달 레이아웃', + description: '이것은 너비가 600px인 모달 레이아웃입니다.', + children: '모달 내용이 여기에 들어갑니다.', + width: '600px', + }, }; // 설명이 없는 모달 스토리 export const NoDescription: Story = { - args: { - title: '설명이 없는 모달 레이아웃', - children: '설명 없이 제목과 내용만 있는 모달입니다.', - width: '400px', - }, + args: { + title: '설명이 없는 모달 레이아웃', + children: '설명 없이 제목과 내용만 있는 모달입니다.', + width: '400px', + }, }; // 내용이 긴 모달 스토리 export const LongContent: Story = { - args: { - onClose: () => { }, - title: '내용이 긴 모달 레이아웃', - description: '스크롤이 필요한 경우를 테스트합니다.', - children: ( -
-

- Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. -

-

- Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. - Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. -

-

- Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, - eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. -

-

- At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti - quos dolores et quas molestias excepturi sint occaecati cupiditate non provident. -

+ args: { + onClose: () => {}, + title: '내용이 긴 모달 레이아웃', + description: '스크롤이 필요한 경우를 테스트합니다.', + children: ( +
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad + minim veniam, quis nostrud exercitation ullamco laboris nisi ut + aliquip ex ea commodo consequat. +

+

+ Duis aute irure dolor in reprehenderit in voluptate velit esse cillum + dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non + proident, sunt in culpa qui officia deserunt mollit anim id est + laborum. +

+

+ Sed ut perspiciatis unde omnis iste natus error sit voluptatem + accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae + ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. +

+

+ At vero eos et accusamus et iusto odio dignissimos ducimus qui + blanditiis praesentium voluptatum deleniti atque corrupti quos dolores + et quas molestias excepturi sint occaecati cupiditate non provident. +

-

- Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, - omnis voluptas assumenda est, omnis dolor repellendus. -

-
- ), - width: '400px', - }, -}; \ No newline at end of file +

+ Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil + impedit quo minus id quod maxime placeat facere possimus, omnis + voluptas assumenda est, omnis dolor repellendus. +

+
+ ), + width: '400px', + }, +}; diff --git a/frontend/src/components/common/Modal/ModalLayout.tsx b/frontend/src/components/common/Modal/ModalLayout.tsx index e8b94506a..561e1b237 100644 --- a/frontend/src/components/common/Modal/ModalLayout.tsx +++ b/frontend/src/components/common/Modal/ModalLayout.tsx @@ -17,21 +17,17 @@ const ModalLayout = ({ width, }: ModalLayoutProps) => { return ( - + {(title || onClose) && ( {title && {title}} {onClose && ( - - ✕ + ✕ )} diff --git a/frontend/src/components/common/Modal/PortalModal.stories.tsx b/frontend/src/components/common/Modal/PortalModal.stories.tsx index acb04dd21..12702dc06 100644 --- a/frontend/src/components/common/Modal/PortalModal.stories.tsx +++ b/frontend/src/components/common/Modal/PortalModal.stories.tsx @@ -40,7 +40,9 @@ const ModalContent = ({ text: string; onClose: () => void; }) => ( -
+
{text}
@@ -62,20 +64,16 @@ export const Default: Story = { const handleClose = () => { setIsOpen(false); args.onClose(); - } + }; return ( <> - - + + ); @@ -96,23 +94,19 @@ export const NoBackdropClose: Story = { const handleClose = () => { setOpen(false); args.onClose(); - } + }; return ( <> - - + ); - } + }, }; diff --git a/frontend/src/components/common/Modal/PortalModal.tsx b/frontend/src/components/common/Modal/PortalModal.tsx index 8f29293bf..c2b5705b3 100644 --- a/frontend/src/components/common/Modal/PortalModal.tsx +++ b/frontend/src/components/common/Modal/PortalModal.tsx @@ -17,7 +17,9 @@ const PortalModal = ({ }: PortalModalProps) => { useEffect(() => { if (isOpen) document.body.style.overflow = 'hidden'; - return () => { document.body.style.overflow = ''; }; + return () => { + document.body.style.overflow = ''; + }; }, [isOpen]); if (!isOpen) return null; @@ -27,7 +29,9 @@ const PortalModal = ({ return createPortal( { if (closeOnBackdrop) onClose();}} + onClick={() => { + if (closeOnBackdrop) onClose(); + }} > ) => e.stopPropagation()} diff --git a/frontend/src/components/common/ScrollToTopButton/ScrollToTopButton.tsx b/frontend/src/components/common/ScrollToTopButton/ScrollToTopButton.tsx index 2ba34bbbc..615353f89 100644 --- a/frontend/src/components/common/ScrollToTopButton/ScrollToTopButton.tsx +++ b/frontend/src/components/common/ScrollToTopButton/ScrollToTopButton.tsx @@ -1,5 +1,5 @@ import scrollButtonIcon from '@/assets/images/icons/scroll_icon.svg'; -import { useScrollTrigger } from '@/hooks/useScrollTrigger'; +import { useScrollTrigger } from '@/hooks/Scroll/useScrollTrigger'; import * as Styled from './ScrollToTopButton.styles'; export const ScrollToTopButton = () => { diff --git a/frontend/src/components/common/SearchField/SearchField.stories.tsx b/frontend/src/components/common/SearchField/SearchField.stories.tsx index bfc5e643f..d36af9cc8 100644 --- a/frontend/src/components/common/SearchField/SearchField.stories.tsx +++ b/frontend/src/components/common/SearchField/SearchField.stories.tsx @@ -1,40 +1,40 @@ +import { useState } from 'react'; import type { Meta, StoryObj } from '@storybook/react'; import SearchField from './SearchField'; -import { useState } from 'react'; const meta = { - title: 'Components/Common/SearchField', - component: SearchField, - parameters: { - layout: 'centered', + title: 'Components/Common/SearchField', + component: SearchField, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + value: { + control: 'text', + description: '검색어 입력값입니다.', + }, + onChange: { + action: 'changed', + description: '입력값이 변경될 때 호출되는 함수입니다.', + }, + onSubmit: { + action: 'submitted', + description: '검색 제출 시 호출되는 함수입니다.', + }, + placeholder: { + control: 'text', + description: '입력창의 플레이스홀더 텍스트입니다.', + }, + ariaLabel: { + control: 'text', + description: '접근성을 위한 aria-label 속성입니다.', }, - tags: ['autodocs'], - argTypes: { - value: { - control: 'text', - description: '검색어 입력값입니다.', - }, - onChange: { - action: 'changed', - description: '입력값이 변경될 때 호출되는 함수입니다.', - }, - onSubmit: { - action: 'submitted', - description: '검색 제출 시 호출되는 함수입니다.', - }, - placeholder: { - control: 'text', - description: '입력창의 플레이스홀더 텍스트입니다.', - }, - ariaLabel: { - control: 'text', - description: '접근성을 위한 aria-label 속성입니다.', - }, - autoBlur: { - control: 'boolean', - description: '제출 후 자동으로 포커스를 해제할지 여부입니다.', - }, + autoBlur: { + control: 'boolean', + description: '제출 후 자동으로 포커스를 해제할지 여부입니다.', }, + }, } satisfies Meta; export default meta; @@ -42,75 +42,75 @@ type Story = StoryObj; // 기본 검색창 스토리 export const Default: Story = { - args: { - value: '', - placeholder: '동아리 이름을 입력하세요', - autoBlur: true, - onChange: () => { }, - onSubmit: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value); + args: { + value: '', + placeholder: '동아리 이름을 입력하세요', + autoBlur: true, + onChange: () => {}, + onSubmit: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value); - return ( - { - setValue(newValue); - args.onChange(newValue); - }} - /> - ); - }, + return ( + { + setValue(newValue); + args.onChange(newValue); + }} + /> + ); + }, }; // 값이 미리 채워진 상태 export const WithValue: Story = { - args: { - value: '밴드 동아리', - placeholder: '검색어를 입력하세요', - autoBlur: true, - onChange: () => { }, - onSubmit: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value); + args: { + value: '밴드 동아리', + placeholder: '검색어를 입력하세요', + autoBlur: true, + onChange: () => {}, + onSubmit: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value); - return ( - { - setValue(newValue); - args.onChange(newValue); - }} - /> - ); - }, + return ( + { + setValue(newValue); + args.onChange(newValue); + }} + /> + ); + }, }; // 커스텀 플레이스홀더 export const CustomPlaceholder: Story = { - args: { - value: '', - placeholder: '원하는 태그를 검색해보세요 (#음악, #운동)', - autoBlur: true, - onChange: () => { }, - onSubmit: () => { }, - }, - render: (args) => { - const [value, setValue] = useState(args.value); + args: { + value: '', + placeholder: '원하는 태그를 검색해보세요 (#음악, #운동)', + autoBlur: true, + onChange: () => {}, + onSubmit: () => {}, + }, + render: (args) => { + const [value, setValue] = useState(args.value); - return ( - { - setValue(newValue); - args.onChange(newValue); - }} - /> - ); - }, + return ( + { + setValue(newValue); + args.onChange(newValue); + }} + /> + ); + }, }; diff --git a/frontend/src/components/common/Spinner/Spinner.stories.tsx b/frontend/src/components/common/Spinner/Spinner.stories.tsx index d7ea6c239..e219d02bc 100644 --- a/frontend/src/components/common/Spinner/Spinner.stories.tsx +++ b/frontend/src/components/common/Spinner/Spinner.stories.tsx @@ -2,18 +2,18 @@ import type { Meta, StoryObj } from '@storybook/react'; import Spinner from './Spinner'; const meta = { - title: 'Components/Common/Spinner', - component: Spinner, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - height: { - control: 'text', - description: '스피너 컨테이너의 높이입니다. (기본값: 100vh)', - }, + title: 'Components/Common/Spinner', + component: Spinner, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + height: { + control: 'text', + description: '스피너 컨테이너의 높이입니다. (기본값: 100vh)', }, + }, } satisfies Meta; export default meta; @@ -21,24 +21,25 @@ type Story = StoryObj; // 기본 스피너 (전체 화면 높이) export const Default: Story = { - args: {}, + args: {}, }; // 커스텀 높이 스피너 export const CustomHeight: Story = { - args: { - height: '200px', - }, - parameters: { - docs: { - description: { - story: '특정 영역 안에서 로딩을 표시할 때 높이를 조절하여 사용할 수 있습니다.', - }, - }, + args: { + height: '200px', + }, + parameters: { + docs: { + description: { + story: + '특정 영역 안에서 로딩을 표시할 때 높이를 조절하여 사용할 수 있습니다.', + }, }, - render: (args) => ( -
- -
- ), + }, + render: (args) => ( +
+ +
+ ), }; diff --git a/frontend/src/components/common/Spinner/Spinner.tsx b/frontend/src/components/common/Spinner/Spinner.tsx index cf9c50bfc..baedf1f2d 100644 --- a/frontend/src/components/common/Spinner/Spinner.tsx +++ b/frontend/src/components/common/Spinner/Spinner.tsx @@ -17,7 +17,7 @@ const spin = keyframes` const SpinnerWrapper = styled.div.attrs(() => ({ role: 'status', 'aria-label': '로딩 중', -})) ` +}))` display: flex; justify-content: center; align-items: center; diff --git a/frontend/src/constants/APPLICATION_FORM.ts b/frontend/src/constants/applicationForm.ts similarity index 100% rename from frontend/src/constants/APPLICATION_FORM.ts rename to frontend/src/constants/applicationForm.ts diff --git a/frontend/src/constants/CLUB_UNION_INFO.ts b/frontend/src/constants/clubUnionInfo.ts similarity index 100% rename from frontend/src/constants/CLUB_UNION_INFO.ts rename to frontend/src/constants/clubUnionInfo.ts diff --git a/frontend/src/constants/INITIAL_FORM_DATA.ts b/frontend/src/constants/initialFormData.ts similarity index 100% rename from frontend/src/constants/INITIAL_FORM_DATA.ts rename to frontend/src/constants/initialFormData.ts diff --git a/frontend/src/constants/photoLayout.ts b/frontend/src/constants/photoLayout.ts deleted file mode 100644 index 3a6ba335f..000000000 --- a/frontend/src/constants/photoLayout.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const CARD_GAP = 20; -export const DESKTOP_CARD_CONTENT_WIDTH = 400; -export const MOBILE_CARD_CONTENT_WIDTH = 350; - -export const DESKTOP_CARD_WIDTH = DESKTOP_CARD_CONTENT_WIDTH + CARD_GAP; -export const MOBILE_CARD_WIDTH = MOBILE_CARD_CONTENT_WIDTH + CARD_GAP; diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts new file mode 100644 index 000000000..2d4721ac3 --- /dev/null +++ b/frontend/src/constants/queryKeys.ts @@ -0,0 +1,22 @@ +export const queryKeys = { + applicants: { + all: ['clubApplicants'] as const, + detail: (applicationFormId: string) => + ['clubApplicants', applicationFormId] as const, + }, + application: { + all: ['applicationForm'] as const, + detail: (clubId: string, applicationFormId: string) => + ['applicationForm', clubId, applicationFormId] as const, + }, + club: { + all: ['clubs'] as const, + detail: (clubId: string) => ['clubDetail', clubId] as const, + list: ( + keyword: string, + recruitmentStatus: string, + category: string, + division: string, + ) => ['clubs', keyword, recruitmentStatus, category, division] as const, + }, +} as const; diff --git a/frontend/src/constants/scrollSections.ts b/frontend/src/constants/scrollSections.ts deleted file mode 100644 index a00e8eb4e..000000000 --- a/frontend/src/constants/scrollSections.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum INFOTABS_SCROLL_INDEX { - INTRODUCE_INFO_TAB = 0, - CLUB_INFO_TAB = 1, - DESCRIPTION_TAB = 2, - PHOTO_LIST_TAB = 3, -} diff --git a/frontend/src/hooks/useAnswers.ts b/frontend/src/hooks/Application/useAnswers.ts similarity index 100% rename from frontend/src/hooks/useAnswers.ts rename to frontend/src/hooks/Application/useAnswers.ts diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts index 4b58760cf..6abfdfdee 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useSearchStore } from '@/store/useSearchStore'; const useHeaderNavigation = () => { diff --git a/frontend/src/hooks/InfoTabs/useAutoScroll.ts b/frontend/src/hooks/InfoTabs/useAutoScroll.ts deleted file mode 100644 index 6b860d93a..000000000 --- a/frontend/src/hooks/InfoTabs/useAutoScroll.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useCallback, useRef } from 'react'; - -/** - * 섹션 자동 스크롤을 위한 커스텀 훅 - * @param sectionCount - 섹션의 총 개수 - * @param yOffset - 스크롤 시 상단 여백 (기본값: -110) - * @returns {Object} sectionRefs와 scrollToSection 함수를 포함한 객체 - */ -const useAutoScroll = (sectionCount: number = 4, yOffset: number = -110) => { - const sectionRefs = useRef<(HTMLDivElement | null)[]>( - new Array(sectionCount).fill(null), - ); - - /** - * 지정된 인덱스의 섹션으로 스크롤 - * @param index - 스크롤할 섹션의 인덱스 - * @returns {boolean} 스크롤 성공 여부 - */ - const scrollToSection = useCallback( - (index: number): boolean => { - if (index < 0 || index >= sectionCount) { - return false; - } - - const element = sectionRefs.current[index]; - - if (!element) { - return false; - } - - try { - window.scrollTo({ - top: element.getBoundingClientRect().top + window.scrollY + yOffset, - behavior: 'smooth', - }); - return true; - } catch (error) { - console.error('Error scrolling to section:', error); - return false; - } - }, - [sectionCount, yOffset], - ); - - return { sectionRefs, scrollToSection }; -}; - -export default useAutoScroll; diff --git a/frontend/src/hooks/useMixpanelTrack.ts b/frontend/src/hooks/Mixpanel/useMixpanelTrack.ts similarity index 100% rename from frontend/src/hooks/useMixpanelTrack.ts rename to frontend/src/hooks/Mixpanel/useMixpanelTrack.ts diff --git a/frontend/src/hooks/useTrackPageView.ts b/frontend/src/hooks/Mixpanel/useTrackPageView.ts similarity index 100% rename from frontend/src/hooks/useTrackPageView.ts rename to frontend/src/hooks/Mixpanel/useTrackPageView.ts diff --git a/frontend/src/hooks/PhotoList/usePhotoModal.ts b/frontend/src/hooks/PhotoList/usePhotoModal.ts deleted file mode 100644 index 0318e1c84..000000000 --- a/frontend/src/hooks/PhotoList/usePhotoModal.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useState } from 'react'; - -export const usePhotoModal = () => { - const [isOpen, setIsOpen] = useState(false); - const [index, setIndex] = useState(0); - - const open = (i: number) => { - setIndex(i); - setIsOpen(true); - }; - const close = () => setIsOpen(false); - - return { isOpen, index, open, close, setIndex }; -}; diff --git a/frontend/src/hooks/PhotoList/usePhotoNavigation.ts b/frontend/src/hooks/PhotoList/usePhotoNavigation.ts deleted file mode 100644 index ed63cc30c..000000000 --- a/frontend/src/hooks/PhotoList/usePhotoNavigation.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useCallback, useEffect } from 'react'; -import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '../useMixpanelTrack'; - -export const usePhotoNavigation = ({ - currentIndex, - setCurrentIndex, - photosLength, - cardWidth, - containerWidth, - setTranslateX, - isMobile, -}: { - currentIndex: number; - setCurrentIndex: React.Dispatch>; - photosLength: number; - cardWidth: number; - containerWidth: number; - translateX: number; - setTranslateX: React.Dispatch>; - isMobile: boolean; -}) => { - const trackEvent = useMixpanelTrack(); - - const calculateTranslateX = useCallback( - (index: number) => -index * cardWidth, - [cardWidth], - ); - - useEffect(() => { - setTranslateX(calculateTranslateX(currentIndex)); - }, [currentIndex, containerWidth, cardWidth, photosLength]); - - const handleNext = () => { - const nextIndex = currentIndex + 1; - if (nextIndex >= photosLength) return; - setCurrentIndex(nextIndex); - trackEvent(USER_EVENT.PHOTO_NAVIGATION_CLICKED, { - action: 'next', - index: nextIndex, - }); - }; - - const handlePrev = () => { - if (currentIndex <= 0) return; - setCurrentIndex(currentIndex - 1); - trackEvent(USER_EVENT.PHOTO_NAVIGATION_CLICKED, { - action: 'prev', - index: currentIndex - 1, - }); - }; - - const canScrollLeft = currentIndex > 0 && photosLength > 1; - const canScrollRight = isMobile - ? currentIndex < photosLength - 1 - : currentIndex < photosLength - 2; - - return { - handlePrev, - handleNext, - canScrollLeft, - canScrollRight, - }; -}; diff --git a/frontend/src/hooks/PhotoList/useResponsiveLayout.ts b/frontend/src/hooks/PhotoList/useResponsiveLayout.ts deleted file mode 100644 index c82bcc24d..000000000 --- a/frontend/src/hooks/PhotoList/useResponsiveLayout.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { DESKTOP_CARD_WIDTH, MOBILE_CARD_WIDTH } from '@/constants/photoLayout'; -import debounce from '@/utils/debounce'; - -export const useResponsiveLayout = ( - ref: React.RefObject, - breakPoint = 500, -) => { - const [isMobile, setIsMobile] = useState( - () => window.innerWidth <= breakPoint, - ); - const [containerWidth, setContainerWidth] = useState(0); - - useEffect(() => { - const updateIsMobile = () => setIsMobile(window.innerWidth <= breakPoint); - const updateContainerWidth = () => { - if (ref.current) { - setContainerWidth(ref.current.offsetWidth); - } - }; - - const handleResize = debounce(() => { - updateIsMobile(); - updateContainerWidth(); - }, 200); - - handleResize(); - window.addEventListener('resize', handleResize); - return () => window.removeEventListener('resize', handleResize); - }, [ref, breakPoint]); - - const cardWidth = useMemo( - () => (isMobile ? MOBILE_CARD_WIDTH : DESKTOP_CARD_WIDTH), - [isMobile], - ); - - return { isMobile, containerWidth, cardWidth }; -}; diff --git a/frontend/src/hooks/PhotoModal/useModalNavigation.ts b/frontend/src/hooks/PhotoModal/useModalNavigation.ts deleted file mode 100644 index 041e02391..000000000 --- a/frontend/src/hooks/PhotoModal/useModalNavigation.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useCallback } from 'react'; - -export default function useModalNavigation( - currentIndex: number, - total: number, - setIndex: (index: number) => void, -) { - const handlePrev = useCallback(() => { - setIndex(currentIndex === 0 ? total - 1 : currentIndex - 1); - }, [currentIndex, total, setIndex]); - - const handleNext = useCallback(() => { - setIndex(currentIndex === total - 1 ? 0 : currentIndex + 1); - }, [currentIndex, total, setIndex]); - - return { handlePrev, handleNext }; -} diff --git a/frontend/src/hooks/Queries/useApplicants.ts b/frontend/src/hooks/Queries/useApplicants.ts new file mode 100644 index 000000000..37318ed87 --- /dev/null +++ b/frontend/src/hooks/Queries/useApplicants.ts @@ -0,0 +1,55 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { deleteApplicants, getClubApplicants } from '@/apis/applicants'; +import { updateApplicantDetail } from '@/apis/application'; +import { queryKeys } from '@/constants/queryKeys'; +import { UpdateApplicantParams } from '@/types/applicants'; + +export const useGetApplicants = (applicationFormId: string | undefined) => { + return useQuery({ + queryKey: applicationFormId + ? queryKeys.applicants.detail(applicationFormId) + : queryKeys.applicants.all, + queryFn: () => getClubApplicants(applicationFormId!), + enabled: !!applicationFormId, + }); +}; + +export const useDeleteApplicants = (applicationFormId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ applicantIds }: { applicantIds: string[] }) => + deleteApplicants(applicantIds, applicationFormId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.applicants.detail(applicationFormId), + }); + }, + onError: (error) => { + console.error(`Error delete applicants detail: ${error}`); + }, + }); +}; + +export const useUpdateApplicant = (applicationFormId: string | undefined) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (applicant: UpdateApplicantParams[]) => { + if (!applicationFormId) { + throw new Error('Application Form ID가 유효하지 않습니다.'); + } + return updateApplicantDetail(applicant, applicationFormId); + }, + onSuccess: () => { + if (applicationFormId) { + queryClient.invalidateQueries({ + queryKey: queryKeys.applicants.detail(applicationFormId), + }); + } + }, + onError: (error) => { + console.error(`Error updating applicant detail: ${error}`); + }, + }); +}; diff --git a/frontend/src/hooks/Queries/useApplication.ts b/frontend/src/hooks/Queries/useApplication.ts new file mode 100644 index 000000000..d49317f1f --- /dev/null +++ b/frontend/src/hooks/Queries/useApplication.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + deleteApplication, + duplicateApplication, + getAllApplicationForms, + getApplication, + updateApplicationStatus, +} from '@/apis/application'; +import { queryKeys } from '@/constants/queryKeys'; + +export const useGetApplication = ( + clubId: string | undefined, + applicationFormId: string | undefined, +) => { + return useQuery({ + queryKey: queryKeys.application.detail( + clubId || 'unknown', + applicationFormId || 'unknown', + ), + queryFn: () => getApplication(clubId!, applicationFormId!), + enabled: !!clubId && !!applicationFormId, + }); +}; + +export const useGetApplicationList = () => { + return useQuery({ + queryKey: queryKeys.application.all, + queryFn: () => getAllApplicationForms(), + }); +}; + +export const useDeleteApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (applicationFormId: string) => + deleteApplication(applicationFormId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.application.all, + }); + }, + onError: (error) => { + console.error(`Error delete application detail: ${error}`); + }, + }); +}; + +export const useDuplicateApplication = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (applicationFormId: string) => + duplicateApplication(applicationFormId), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.application.all, + }); + }, + onError: (error) => { + console.error(`Error duplicating application: ${error}`); + }, + }); +}; + +export const useUpdateApplicationStatus = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + applicationFormId, + currentStatus, + }: { + applicationFormId: string; + currentStatus: string; + }) => updateApplicationStatus(applicationFormId, currentStatus), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.application.all, + }); + }, + onError: (error) => { + console.error('Error updating application status:', error); + }, + }); +}; diff --git a/frontend/src/hooks/Queries/useClub.ts b/frontend/src/hooks/Queries/useClub.ts new file mode 100644 index 000000000..bd64cb31f --- /dev/null +++ b/frontend/src/hooks/Queries/useClub.ts @@ -0,0 +1,103 @@ +import { + keepPreviousData, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; +import { + getClubDetail, + getClubList, + updateClubDescription, + updateClubDetail, +} from '@/apis/club'; +import { queryKeys } from '@/constants/queryKeys'; +import { ClubDescription, ClubDetail, ClubSearchResponse } from '@/types/club'; +import convertToDriveUrl from '@/utils/convertGoogleDriveUrl'; +import convertGoogleDriveUrl from '@/utils/convertGoogleDriveUrl'; + +interface UseGetCardListProps { + keyword: string; + recruitmentStatus: string; + category: string; + division: string; +} + +export const useGetClubDetail = (clubId: string) => { + return useQuery({ + queryKey: queryKeys.club.detail(clubId), + queryFn: () => getClubDetail(clubId as string), + staleTime: 60 * 1000, + enabled: !!clubId, + select: (data) => + ({ + ...data, + logo: data.logo ? convertGoogleDriveUrl(data.logo) : undefined, + feeds: Array.isArray(data.feeds) + ? data.feeds.map(convertGoogleDriveUrl) + : [], + }) as ClubDetail, + }); +}; + +export const useGetCardList = ({ + keyword, + recruitmentStatus, + category, + division, +}: UseGetCardListProps) => { + return useQuery({ + queryKey: queryKeys.club.list( + keyword, + recruitmentStatus, + category, + division, + ), + queryFn: () => getClubList(keyword, recruitmentStatus, category, division), + staleTime: 60 * 1000, + placeholderData: keepPreviousData, + select: (data) => ({ + totalCount: data.totalCount, + clubs: data.clubs.map((club) => ({ + ...club, + logo: convertToDriveUrl(club.logo), + })), + }), + }); +}; + +export const useUpdateClubDescription = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (updatedData: ClubDescription) => + updateClubDescription(updatedData), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(variables.id), + }); + }, + onError: (error) => { + console.error('Error updating club detail:', error); + }, + }); +}; + +export const useUpdateClubDetail = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (updatedData: Partial) => + updateClubDetail(updatedData), + onSuccess: (_, variables) => { + if (variables.id) { + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(variables.id), + }); + } + }, + + onError: (error) => { + console.error('Error updating club detail:', error); + }, + }); +}; diff --git a/frontend/src/hooks/queries/club/cover/useCoverMutation.ts b/frontend/src/hooks/Queries/useClubCover.ts similarity index 67% rename from frontend/src/hooks/queries/club/cover/useCoverMutation.ts rename to frontend/src/hooks/Queries/useClubCover.ts index 5c1a80688..58b3c6480 100644 --- a/frontend/src/hooks/queries/club/cover/useCoverMutation.ts +++ b/frontend/src/hooks/Queries/useClubCover.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { coverApi } from '@/apis/image/cover'; -import { uploadToStorage } from '@/apis/image/uploadToStorage'; +import { coverApi, uploadToStorage } from '@/apis/image'; +import { queryKeys } from '@/constants/queryKeys'; interface CoverUploadParams { clubId: string; @@ -26,11 +26,13 @@ export const useUploadCover = () => { }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', data.clubId] }); + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(data.clubId), + }); }, onError: () => { - alert('커버 이미지 업로드에 실패했어요. 다시 시도해주세요!'); + console.error('Error uploading cover'); }, }); }; @@ -45,11 +47,13 @@ export const useDeleteCover = () => { }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', data.clubId] }); + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(data.clubId), + }); }, onError: () => { - alert('커버 이미지 삭제에 실패했어요. 다시 시도해주세요!'); + console.error('Error deleting cover'); }, }); }; diff --git a/frontend/src/hooks/queries/club/images/useFeedMutation.ts b/frontend/src/hooks/Queries/useClubImages.ts similarity index 56% rename from frontend/src/hooks/queries/club/images/useFeedMutation.ts rename to frontend/src/hooks/Queries/useClubImages.ts index 35db567e3..cdec8bdd7 100644 --- a/frontend/src/hooks/queries/club/images/useFeedMutation.ts +++ b/frontend/src/hooks/Queries/useClubImages.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { feedApi } from '@/apis/image/feed'; -import { uploadToStorage } from '@/apis/image/uploadToStorage'; +import { feedApi, logoApi, uploadToStorage } from '@/apis/image'; +import { queryKeys } from '@/constants/queryKeys'; interface FeedUploadParams { clubId: string; @@ -13,7 +13,11 @@ interface FeedUpdateParams { urls: string[]; } -// 피드 업로드(새 파일 업로드 + 기존 피드와 합쳐서 서버 갱신) +interface LogoUploadParams { + clubId: string; + file: File; +} + export const useUploadFeed = () => { const queryClient = useQueryClient(); @@ -61,40 +65,81 @@ export const useUploadFeed = () => { }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', data.clubId] }); - - // 부분 실패한 경우 사용자에게 알림 - if (data.failedFiles.length > 0) { - const failedFileNames = data.failedFiles.join(', '); - alert( - `일부 파일 업로드에 실패했어요.\n실패한 파일: ${failedFileNames}\n\n성공한 파일은 정상적으로 등록되었어요.`, - ); - } + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(data.clubId), + }); }, - // TODO: 각 API 에러 응답에 따른 세분화된 에러 메시지 전달 - // 참고: feedApi.updateFeeds, uploadToStorage 에러 스펙 확인 후 분기 onError: () => { - alert('이미지 업로드에 실패했어요. 다시 시도해주세요!'); + console.error('Error uploading feed images'); }, }); }; -// 피드 업데이트 (기존 피드 URL 배열만 서버에 갱신) export const useUpdateFeed = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ clubId, urls }: FeedUpdateParams) => { - // 1. 서버에 URL 배열 PUT으로 갱신 await feedApi.updateFeeds(clubId, urls); return { clubId }; }, onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', data.clubId] }); + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(data.clubId), + }); + }, + onError: () => { + console.error('Error updating feed images'); + }, + }); +}; + +export const useUploadLogo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ clubId, file }: LogoUploadParams) => { + // 1. presigned URL 받기 + const { presignedUrl, finalUrl } = await logoApi.getUploadUrl( + clubId, + file.name, + file.type, + ); + + // 2. r2 업로드 + await uploadToStorage(presignedUrl, file); + + // 3. 완료 처리 + await logoApi.completeUpload(clubId, finalUrl); + + return { finalUrl, clubId }; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(data.clubId), + }); + }, + onError: () => { + console.error('Error uploading logo'); + }, + }); +}; + +export const useDeleteLogo = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (clubId: string) => { + await logoApi.delete(clubId); + return clubId; + }, + onSuccess: (clubId) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.club.detail(clubId), + }); }, - // TODO: 각 API 에러 응답에 따른 세분화된 에러 메시지 전달 onError: () => { - alert('이미지 수정에 실패했어요. 다시 시도해주세요!'); + console.error('Error deleting logo'); }, }); }; diff --git a/frontend/src/hooks/ScrollToTop.tsx b/frontend/src/hooks/Scroll/ScrollToTop.tsx similarity index 100% rename from frontend/src/hooks/ScrollToTop.tsx rename to frontend/src/hooks/Scroll/ScrollToTop.tsx diff --git a/frontend/src/hooks/useScrollDetection.ts b/frontend/src/hooks/Scroll/useScrollDetection.ts similarity index 100% rename from frontend/src/hooks/useScrollDetection.ts rename to frontend/src/hooks/Scroll/useScrollDetection.ts diff --git a/frontend/src/hooks/useScrollTrigger.ts b/frontend/src/hooks/Scroll/useScrollTrigger.ts similarity index 100% rename from frontend/src/hooks/useScrollTrigger.ts rename to frontend/src/hooks/Scroll/useScrollTrigger.ts diff --git a/frontend/src/hooks/queries/applicants/useDeleteApplicants.ts b/frontend/src/hooks/queries/applicants/useDeleteApplicants.ts deleted file mode 100644 index 4b7de159d..000000000 --- a/frontend/src/hooks/queries/applicants/useDeleteApplicants.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import deleteApplicants from '@/apis/applicants/deleteApplicants'; - -export const useDeleteApplicants = (applicationFormId: string) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: ({ applicantIds }: { applicantIds: string[] }) => - deleteApplicants(applicantIds, applicationFormId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['clubApplicants', applicationFormId], - }); - }, - onError: (error) => { - console.error(`Error delete applicants detail: ${error}`); - }, - }); -}; diff --git a/frontend/src/hooks/queries/applicants/useGetApplicants.ts b/frontend/src/hooks/queries/applicants/useGetApplicants.ts deleted file mode 100644 index 3811ec683..000000000 --- a/frontend/src/hooks/queries/applicants/useGetApplicants.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import getClubApplicants from '@/apis/applicants/getClubApplicants'; - -export const useGetApplicants = (applicationFormId: string | undefined) => { - return useQuery({ - queryKey: ['clubApplicants', applicationFormId], - queryFn: () => getClubApplicants(applicationFormId!), - retry: false, - enabled: !!applicationFormId, - }); -}; diff --git a/frontend/src/hooks/queries/applicants/useUpdateApplicant.ts b/frontend/src/hooks/queries/applicants/useUpdateApplicant.ts deleted file mode 100644 index 58c2ed7cd..000000000 --- a/frontend/src/hooks/queries/applicants/useUpdateApplicant.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { updateApplicantDetail } from '@/apis/application/updateApplicantDetail'; -import { UpdateApplicantParams } from '@/types/applicants'; - -export const useUpdateApplicant = (applicationFormId: string | undefined) => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (applicant: UpdateApplicantParams[]) => { - if (!applicationFormId) { - throw new Error('Application Form ID가 유효하지 않습니다.'); - } - return updateApplicantDetail(applicant, applicationFormId); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['clubApplicants', applicationFormId], - }); - }, - onError: (error) => { - console.log(`Error updating applicant detail: ${error}`); - }, - }); -}; diff --git a/frontend/src/hooks/queries/application/useDeleteApplication.ts b/frontend/src/hooks/queries/application/useDeleteApplication.ts deleted file mode 100644 index 6d106b180..000000000 --- a/frontend/src/hooks/queries/application/useDeleteApplication.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import deleteApplication from '@/apis/application/deleteApplication'; - -export const useDeleteApplication = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (applicationFormId: string) => - deleteApplication(applicationFormId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['applicationForm'], - }); - }, - onError: (error) => { - console.error(`Error delete application detail: ${error}`); - }, - }); -}; diff --git a/frontend/src/hooks/queries/application/useDuplicateApplication.ts b/frontend/src/hooks/queries/application/useDuplicateApplication.ts deleted file mode 100644 index 756884205..000000000 --- a/frontend/src/hooks/queries/application/useDuplicateApplication.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { duplicateApplication } from '@/apis/application/duplicateApplication'; - -export const useDuplicateApplication = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (applicationFormId: string) => - duplicateApplication(applicationFormId), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ['applicationForm'], - }); - }, - onError: (error) => { - console.error(`Error duplicating application: ${error}`); - }, - }); -}; diff --git a/frontend/src/hooks/queries/application/useGetApplication.ts b/frontend/src/hooks/queries/application/useGetApplication.ts deleted file mode 100644 index 4914978da..000000000 --- a/frontend/src/hooks/queries/application/useGetApplication.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import getApplication from '@/apis/application/getApplication'; - -export const useGetApplication = ( - clubId: string | undefined, - applicationFormId: string | undefined, -) => { - return useQuery({ - queryKey: ['applicationForm', clubId, applicationFormId], - queryFn: () => getApplication(clubId!, applicationFormId!), - retry: false, - enabled: !!clubId && !!applicationFormId, - }); -}; diff --git a/frontend/src/hooks/queries/application/useGetApplicationlist.ts b/frontend/src/hooks/queries/application/useGetApplicationlist.ts deleted file mode 100644 index d2ec2ea7a..000000000 --- a/frontend/src/hooks/queries/application/useGetApplicationlist.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import getAllApplications from '@/apis/application/getAllApplications'; - -export const useGetApplicationlist = () => { - return useQuery({ - queryKey: ['applicationForm'], - queryFn: () => getAllApplications(), - retry: false, - }); -}; -export default useGetApplicationlist; diff --git a/frontend/src/hooks/queries/club/images/useLogoMutation.ts b/frontend/src/hooks/queries/club/images/useLogoMutation.ts deleted file mode 100644 index b9d82a353..000000000 --- a/frontend/src/hooks/queries/club/images/useLogoMutation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { logoApi } from '@/apis/image/logo'; -import { uploadToStorage } from '@/apis/image/uploadToStorage'; - -interface LogoUploadParams { - clubId: string; - file: File; -} - -// 로고 업로드 (presigned URL 발급 → r2 업로드 → 완료 처리) -export const useUploadLogo = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ clubId, file }: LogoUploadParams) => { - // 1. presigned URL 받기 - const { presignedUrl, finalUrl } = await logoApi.getUploadUrl( - clubId, - file.name, - file.type, - ); - - // 2. r2 업로드 - await uploadToStorage(presignedUrl, file); - - // 3. 완료 처리 - await logoApi.completeUpload(clubId, finalUrl); - - return { finalUrl, clubId }; - }, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', data.clubId] }); - }, - // TODO: 각 API 에러 응답에 따른 세분화된 에러 메시지 전달 - onError: () => { - alert('로고 업로드에 실패했어요. 다시 시도해주세요!'); - }, - }); -}; - -// 로고 삭제 -export const useDeleteLogo = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (clubId: string) => { - await logoApi.delete(clubId); - return clubId; - }, - onSuccess: (clubId) => { - queryClient.invalidateQueries({ queryKey: ['clubDetail', clubId] }); - }, - // TODO: 각 API 에러 응답에 따른 세분화된 에러 메시지 전달 - onError: () => { - alert('로고 초기화에 실패했어요. 다시 시도해 주세요.'); - }, - }); -}; diff --git a/frontend/src/hooks/queries/club/useGetCardList.ts b/frontend/src/hooks/queries/club/useGetCardList.ts deleted file mode 100644 index 5dcce8d63..000000000 --- a/frontend/src/hooks/queries/club/useGetCardList.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { getClubList } from '@/apis/getClubList'; -import { ClubSearchResponse } from '@/types/club.responses'; -import convertToDriveUrl from '@/utils/convertGoogleDriveUrl'; - -interface UseGetCardListProps { - keyword: string; - recruitmentStatus: string; - category: string; - division: string; -} - -export const useGetCardList = ({ - keyword, - recruitmentStatus, - category, - division, -}: UseGetCardListProps) => { - return useQuery({ - queryKey: ['clubs', keyword, recruitmentStatus, category, division], - queryFn: () => getClubList(keyword, recruitmentStatus, category, division), - placeholderData: keepPreviousData, - select: (data) => ({ - totalCount: data.totalCount, - clubs: data.clubs.map((club) => ({ - ...club, - logo: convertToDriveUrl(club.logo), - })), - }), - }); -}; diff --git a/frontend/src/hooks/queries/club/useGetClubDetail.ts b/frontend/src/hooks/queries/club/useGetClubDetail.ts deleted file mode 100644 index dca982417..000000000 --- a/frontend/src/hooks/queries/club/useGetClubDetail.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { getClubDetail } from '@/apis/getClubDetail'; -import { ClubDetail } from '@/types/club'; -import convertGoogleDriveUrl from '@/utils/convertGoogleDriveUrl'; - -export const useGetClubDetail = (clubId: string) => { - return useQuery({ - queryKey: ['clubDetail', clubId], - queryFn: () => getClubDetail(clubId as string), - enabled: !!clubId, - select: (data) => - ({ - ...data, - logo: data.logo ? convertGoogleDriveUrl(data.logo) : undefined, - feeds: Array.isArray(data.feeds) - ? data.feeds.map(convertGoogleDriveUrl) - : [], - }) as ClubDetail, - }); -}; diff --git a/frontend/src/hooks/queries/club/useUpdateClubDescription.ts b/frontend/src/hooks/queries/club/useUpdateClubDescription.ts deleted file mode 100644 index fcdedb1de..000000000 --- a/frontend/src/hooks/queries/club/useUpdateClubDescription.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { updateClubDescription } from '@/apis/updateClubDescription'; -import { ClubDescription } from '@/types/club'; - -export const useUpdateClubDescription = () => { - return useMutation({ - mutationFn: (updatedData: ClubDescription) => - updateClubDescription(updatedData), - - onError: (error) => { - console.error('Error updating club detail:', error); - }, - }); -}; diff --git a/frontend/src/hooks/queries/club/useUpdateClubDetail.ts b/frontend/src/hooks/queries/club/useUpdateClubDetail.ts deleted file mode 100644 index d33429671..000000000 --- a/frontend/src/hooks/queries/club/useUpdateClubDetail.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; -import { updateClubDetail } from '@/apis/updateClubDetail'; -import { ClubDetail } from '@/types/club'; - -export const useUpdateClubDetail = () => { - return useMutation({ - mutationFn: (updatedData: Partial) => - updateClubDetail(updatedData), - - onError: (error) => { - console.error('Error updating club detail:', error); - }, - }); -}; diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 9e72244d9..25cc8efc2 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { getClubIdByToken } from '@/apis/auth/getClubIdByToken'; +import { getClubIdByToken } from '@/apis/auth'; const useAuth = () => { const [isLoading, setIsLoading] = useState(true); diff --git a/frontend/src/hooks/__tests__/useNavigator.test.ts b/frontend/src/hooks/useNavigator.test.ts similarity index 98% rename from frontend/src/hooks/__tests__/useNavigator.test.ts rename to frontend/src/hooks/useNavigator.test.ts index 3177caa5a..0c3c18cd3 100644 --- a/frontend/src/hooks/__tests__/useNavigator.test.ts +++ b/frontend/src/hooks/useNavigator.test.ts @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; import { renderHook, RenderHookResult } from '@testing-library/react'; -import useNavigator from '../useNavigator'; +import useNavigator from '@/hooks/useNavigator'; jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), diff --git a/frontend/src/pages/AdminPage/AdminPage.tsx b/frontend/src/pages/AdminPage/AdminPage.tsx index 2755c0aa6..7a10ab00d 100644 --- a/frontend/src/pages/AdminPage/AdminPage.tsx +++ b/frontend/src/pages/AdminPage/AdminPage.tsx @@ -1,7 +1,7 @@ import { Outlet } from 'react-router-dom'; import Header from '@/components/common/Header/Header'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import SideBar from '@/pages/AdminPage/components/SideBar/SideBar'; import * as Styled from './AdminPage.styles'; diff --git a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx index ef9298d97..334e9f31e 100644 --- a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx +++ b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx @@ -1,15 +1,15 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { login } from '@/apis/auth/login'; +import { login } from '@/apis/auth'; import moadong_name_logo from '@/assets/images/logos/moadong_name_logo.svg'; import Button from '@/components/common/Button/Button'; +import Header from '@/components/common/Header/Header'; import InputField from '@/components/common/InputField/InputField'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import useAuth from '@/hooks/useAuth'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; import * as Styled from './LoginTab.styles'; -import Header from '@/components/common/Header/Header'; const LoginTab = () => { useTrackPageView(PAGE_VIEW.LOGIN_PAGE); @@ -54,80 +54,80 @@ const LoginTab = () => { return ( <> -
- - - - Log in - { - e.preventDefault(); - handleLogin(); - }} - > - - setUserId(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - - - - - - - { - trackEvent(ADMIN_EVENT.SIGNUP_BUTTON_CLICKED); - alert( - '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', - ); - }} - > - 회원가입 - - | - { - trackEvent(ADMIN_EVENT.FORGOT_ID_BUTTON_CLICKED); - alert( - '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', - ); - }} - > - 아이디 찾기 - - | - { - trackEvent(ADMIN_EVENT.FORGOT_PASSWORD_BUTTON_CLICKED); - alert( - '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', - ); +
+ + + + Log in + { + e.preventDefault(); + handleLogin(); }} > - 비밀번호 찾기 - - - - + + setUserId(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + + + + + + + { + trackEvent(ADMIN_EVENT.SIGNUP_BUTTON_CLICKED); + alert( + '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', + ); + }} + > + 회원가입 + + | + { + trackEvent(ADMIN_EVENT.FORGOT_ID_BUTTON_CLICKED); + alert( + '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', + ); + }} + > + 아이디 찾기 + + | + { + trackEvent(ADMIN_EVENT.FORGOT_PASSWORD_BUTTON_CLICKED); + alert( + '해당 기능은 아직 준비 중이에요.\n필요하신 경우 관리자에게 문의해주세요☺', + ); + }} + > + 비밀번호 찾기 + + + + ); }; diff --git a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx index b685bbb68..b1f69a190 100644 --- a/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor.tsx @@ -3,11 +3,8 @@ import defaultCover from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { - useDeleteCover, - useUploadCover, -} from '@/hooks/queries/club/cover/useCoverMutation'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useDeleteCover, useUploadCover } from '@/hooks/Queries/useClubCover'; import * as Styled from './ClubCoverEditor.styles'; interface ClubCoverEditorProps { @@ -42,7 +39,14 @@ const ClubCoverEditor = ({ coverImage }: ClubCoverEditorProps) => { } trackEvent(ADMIN_EVENT.CLUB_COVER_UPLOAD_BUTTON_CLICKED); - uploadMutation.mutate({ clubId, file }); + uploadMutation.mutate( + { clubId, file }, + { + onError: () => { + alert('커버 이미지 업로드에 실패했어요. 다시 시도해주세요!'); + }, + }, + ); }; const triggerFileInput = () => { @@ -59,7 +63,11 @@ const ClubCoverEditor = ({ coverImage }: ClubCoverEditorProps) => { if (!window.confirm('정말 커버 이미지를 기본 이미지로 되돌릴까요?')) return; trackEvent(ADMIN_EVENT.CLUB_COVER_RESET_BUTTON_CLICKED); - deleteMutation.mutate(clubId); + deleteMutation.mutate(clubId, { + onError: () => { + alert('커버 이미지 초기화에 실패했어요. 다시 시도해주세요!'); + }, + }); }; return ( diff --git a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx index e75f65ca4..ab0252c30 100644 --- a/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx +++ b/frontend/src/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor.tsx @@ -3,11 +3,8 @@ import defaultLogo from '@/assets/images/logos/default_profile_image.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; import { MAX_FILE_SIZE } from '@/constants/uploadLimit'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { - useDeleteLogo, - useUploadLogo, -} from '@/hooks/queries/club/images/useLogoMutation'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useDeleteLogo, useUploadLogo } from '@/hooks/Queries/useClubImages'; import * as Styled from './ClubLogoEditor.styles'; interface ClubLogoEditorProps { @@ -41,7 +38,14 @@ const ClubLogoEditor = ({ clubLogo }: ClubLogoEditorProps) => { } trackEvent(ADMIN_EVENT.CLUB_LOGO_UPLOAD_BUTTON_CLICKED); - uploadMutation.mutate({ clubId, file }); + uploadMutation.mutate( + { clubId, file }, + { + onError: () => { + alert('로고 업로드에 실패했어요. 다시 시도해주세요!'); + }, + }, + ); }; const triggerFileInput = () => { @@ -58,7 +62,11 @@ const ClubLogoEditor = ({ clubLogo }: ClubLogoEditorProps) => { if (!window.confirm('정말 로고를 기본 이미지로 되돌릴까요?')) return; trackEvent(ADMIN_EVENT.CLUB_LOGO_RESET_BUTTON_CLICKED); - deleteMutation.mutate(clubId); + deleteMutation.mutate(clubId, { + onError: () => { + alert('로고 초기화에 실패했어요. 다시 시도해 주세요.'); + }, + }); }; return ( diff --git a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx index 95ec9ca50..74b679bec 100644 --- a/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx +++ b/frontend/src/pages/AdminPage/components/QuestionBuilder/QuestionBuilder.tsx @@ -8,7 +8,7 @@ import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDow import { DROPDOWN_OPTIONS, QUESTION_LABEL_MAP, -} from '@/constants/APPLICATION_FORM'; +} from '@/constants/applicationForm'; import { QuestionBuilderProps, QuestionType } from '@/types/application'; import * as Styled from './QuestionBuilder.styles'; diff --git a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx index 6be53880a..9d6367fbc 100644 --- a/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx +++ b/frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import { logout } from '@/apis/auth/logout'; +import { logout } from '@/apis/auth'; import { ADMIN_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './SideBar.styles'; interface TabItem { diff --git a/frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.tsx b/frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.tsx index 712867c80..62a4543f1 100644 --- a/frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/AccountEditTab/AccountEditTab.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import { changePassword } from '@/apis/auth/changePassword'; +import { changePassword } from '@/apis/auth'; import Button from '@/components/common/Button/Button'; import InputField from '@/components/common/InputField/InputField'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import * as Styled from './AccountEditTab.styles'; diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 798256a14..6342f2011 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -6,8 +6,8 @@ import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; import { AVAILABLE_STATUSES } from '@/constants/status'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { useUpdateApplicant } from '@/hooks/queries/applicants/useUpdateApplicant'; -import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; +import { useUpdateApplicant } from '@/hooks/Queries/useApplicants'; +import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer'; import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer'; import { ApplicationStatus } from '@/types/applicants'; @@ -77,13 +77,20 @@ const ApplicantDetailPage = () => { if (typeof memo !== 'string') return; if (!isApplicationStatus(status)) return; - updateApplicant([ + updateApplicant( + [ + { + memo, + status, + applicantId: questionId, + }, + ], { - memo, - status, - applicantId: questionId, + onError: () => { + alert('지원자 정보 수정에 실패했습니다.'); + }, }, - ]); + ); }, 400), [clubId, questionId, updateApplicant], ); @@ -98,7 +105,6 @@ const ApplicantDetailPage = () => { if (isLoading) return ; if (isError || !formData) return
지원서 정보를 불러올 수 없습니다.
; - // questionId로 지원자 찾기 if (!applicant) { return
해당 지원자를 찾을 수 없습니다.
; } diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab.tsx index 423cc88bb..afd5a7b17 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsListTab/ApplicantsListTab.tsx @@ -2,24 +2,32 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import styled from 'styled-components'; -import { updateApplicationStatus } from '@/apis/application/updateApplication'; +import { updateApplicationStatus } from '@/apis/application'; import expandArrow from '@/assets/images/icons/ExpandArrow.svg'; import Plus from '@/assets/images/icons/Plus.svg'; import Spinner from '@/components/common/Spinner/Spinner'; -import { useDeleteApplication } from '@/hooks/queries/application/useDeleteApplication'; -import { useGetApplicationlist } from '@/hooks/queries/application/useGetApplicationlist'; +import { + useDeleteApplication, + useDuplicateApplication, + useGetApplicationList, + useUpdateApplicationStatus, +} from '@/hooks/Queries/useApplication'; import ApplicationRowItem from '@/pages/AdminPage/components/ApplicationRow/ApplicationRowItem'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import * as Styled from '@/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab.styles'; import { ApplicationFormItem, SemesterGroup } from '@/types/application'; +const MAX_INITIAL_ITEMS = 3; + const ApplicationListTab = () => { - const { data: allforms, isLoading, isError, error } = useGetApplicationlist(); - const queryClient = useQueryClient(); - const navigate = useNavigate(); + const { data: allforms, isLoading, isError, error } = useGetApplicationList(); const { mutate: deleteApplication } = useDeleteApplication(); + const { mutate: duplicateApplication } = useDuplicateApplication(); + const { mutate: updateStatus } = useUpdateApplicationStatus(); + + const navigate = useNavigate(); + const [isExpanded, setIsExpanded] = useState(false); - const MAX_INITIAL_ITEMS = 3; const handleGoToNewForm = () => { navigate('/admin/application-list/edit'); @@ -29,7 +37,6 @@ const ApplicationListTab = () => { }; const handleDeleteApplication = (applicationFormId: string) => { - // 사용자에게 재확인 if ( window.confirm( '지원서 양식을 정말 삭제하시겠습니까?\n삭제된 양식은 복구할 수 없습니다.', @@ -38,8 +45,22 @@ const ApplicationListTab = () => { deleteApplication(applicationFormId, { onSuccess: () => { setOpenMenuId(null); - // 성공 알림 - alert('삭제되었습니다.'); + }, + onError: () => { + alert('삭제에 실패했습니다.'); + }, + }); + } + }; + + const handleDuplicateApplication = (applicationFormId: string) => { + if (window.confirm('이 지원서 양식을 복제하시겠습니까?')) { + duplicateApplication(applicationFormId, { + onSuccess: () => { + setOpenMenuId(null); + }, + onError: () => { + alert('지원서 복제에 실패했습니다.'); }, }); } @@ -49,14 +70,17 @@ const ApplicationListTab = () => { applicationFormId: string, currentStatus: string, ) => { - try { - await updateApplicationStatus(applicationFormId, currentStatus); - queryClient.invalidateQueries({ queryKey: ['applicationForm'] }); - setOpenMenuId(null); - } catch (error) { - console.error('지원서 상태 변경 실패:', error); - alert('상태 변경에 실패했습니다.'); - } + updateStatus( + { applicationFormId, currentStatus }, + { + onSuccess: () => { + setOpenMenuId(null); + }, + onError: () => { + alert('상태 변경에 실패했습니다.'); + }, + }, + ); }; const handleToggleExpand = () => { @@ -71,30 +95,26 @@ const ApplicationListTab = () => { id: string, contextPrefix: string, ) => { - e.stopPropagation(); // 이벤트 버블링 방지 (row 전체가 클릭되지 않도록) + e.stopPropagation(); const uniqueKey = `${contextPrefix}-${id}`; - setOpenMenuId(openMenuId === uniqueKey ? null : uniqueKey); // 같은 버튼 누르면 닫기, 다른 버튼 누르면 열기 + setOpenMenuId(openMenuId === uniqueKey ? null : uniqueKey); }; useEffect(() => { - //더보기 메뉴 외부 클릭 시 메뉴 닫기 const handleOutsideClick = (e: MouseEvent) => { - // menuRef.current가 있고, 클릭된 영역이 메뉴 영역(menuRef.current)에 포함되지 않을 때 if (menuRef.current && !menuRef.current.contains(e.target as Node)) { - setOpenMenuId(null); // 메뉴를 닫습니다. + setOpenMenuId(null); } }; - // 메뉴가 열려 있을 때만 이벤트 리스너를 추가합니다. if (openMenuId !== null) { document.addEventListener('mousedown', handleOutsideClick); } - // 클린업 함수: 컴포넌트가 사라지거나, openMenuId가 바뀌기 전에 리스너를 제거합니다. return () => { document.removeEventListener('mousedown', handleOutsideClick); }; - }, [openMenuId]); // openMenuId가 변경될 때마다 이 훅을 다시 실행합니다. + }, [openMenuId]); if (isLoading) { return ; @@ -150,6 +170,7 @@ const ApplicationListTab = () => { onEdit={handleGoToDetailForm} onMenuToggle={handleMenuToggle} onDelete={handleDeleteApplication} + onDuplicate={handleDuplicateApplication} /> ))} {showExpandButton && ( @@ -206,6 +227,7 @@ const ApplicationListTab = () => { onMenuToggle={handleMenuToggle} onToggleStatus={handleToggleClick} onDelete={handleDeleteApplication} + onDuplicate={handleDuplicateApplication} /> ))} diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx index 1a0319c39..626bf5625 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx @@ -7,16 +7,25 @@ import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDow import SearchField from '@/components/common/SearchField/SearchField'; import { AVAILABLE_STATUSES } from '@/constants/status'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { useDeleteApplicants } from '@/hooks/queries/applicants/useDeleteApplicants'; -import { useGetApplicants } from '@/hooks/queries/applicants/useGetApplicants'; -import { useUpdateApplicant } from '@/hooks/queries/applicants/useUpdateApplicant'; +import { + useDeleteApplicants, + useGetApplicants, + useUpdateApplicant, +} from '@/hooks/Queries/useApplicants'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import { Applicant, ApplicationStatus } from '@/types/applicants'; import mapStatusToGroup from '@/utils/mapStatusToGroup'; import * as Styled from './ApplicantsTab.styles'; +const sortOptions = [ + { value: 'date', label: '제출순' }, + { value: 'name', label: '이름순' }, +] as const; + const ApplicantsTab = () => { + const { clubId, applicantsData, setApplicantsData } = useAdminClubContext(); const { applicationFormId } = useParams<{ applicationFormId: string }>(); + const navigate = useNavigate(); const statusOptions = AVAILABLE_STATUSES.map((status) => ({ value: status, @@ -33,13 +42,6 @@ const ApplicantsTab = () => { }), ); - const sortOptions = [ - { value: 'date', label: '제출순' }, - { value: 'name', label: '이름순' }, - ] as const; - - const navigate = useNavigate(); - const { clubId, applicantsData, setApplicantsData } = useAdminClubContext(); const { data: fetchData, isLoading, @@ -66,7 +68,6 @@ const ApplicantsTab = () => { } }, [fetchData, setApplicantsData]); - // 모든 드롭다운을 닫는 함수 const closeAllDropdowns = () => { if (open) setOpen(false); if (isStatusDropdownOpen) setIsStatusDropdownOpen(false); diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx index b9b06f27d..17e8a6ee3 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx @@ -1,14 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { createApplication } from '@/apis/application/createApplication'; -import { updateApplication } from '@/apis/application/updateApplication'; +import { createApplication, updateApplication } from '@/apis/application'; import Button from '@/components/common/Button/Button'; import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; -import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; -import INITIAL_FORM_DATA from '@/constants/INITIAL_FORM_DATA'; +import { APPLICATION_FORM } from '@/constants/applicationForm'; +import INITIAL_FORM_DATA from '@/constants/initialFormData'; import { useAdminClubContext } from '@/context/AdminClubContext'; -import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; +import { useGetApplication } from '@/hooks/Queries/useApplication'; import QuestionBuilder from '@/pages/AdminPage/components/QuestionBuilder/QuestionBuilder'; import { PageContainer } from '@/styles/PageContainer.styles'; import { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab.tsx index 696bae497..a7b7e0730 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationListTab/ApplicationListTab.tsx @@ -2,13 +2,15 @@ import React, { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import styled from 'styled-components'; -import { updateApplicationStatus } from '@/apis/application/updateApplication'; +import { updateApplicationStatus } from '@/apis/application'; import expandArrow from '@/assets/images/icons/ExpandArrow.svg'; import Plus from '@/assets/images/icons/Plus.svg'; import Spinner from '@/components/common/Spinner/Spinner'; -import { useDeleteApplication } from '@/hooks/queries/application/useDeleteApplication'; -import { useDuplicateApplication } from '@/hooks/queries/application/useDuplicateApplication'; -import { useGetApplicationlist } from '@/hooks/queries/application/useGetApplicationlist'; +import { + useDeleteApplication, + useDuplicateApplication, + useGetApplicationList, +} from '@/hooks/Queries/useApplication'; import ApplicationRowItem from '@/pages/AdminPage/components/ApplicationRow/ApplicationRowItem'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import { ApplicationFormItem, SemesterGroup } from '@/types/application'; @@ -19,7 +21,7 @@ const MAX_INITIAL_ITEMS = 3; const ApplicationListTab = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); - const { data: allforms, isLoading, isError, error } = useGetApplicationlist(); + const { data: allforms, isLoading, isError, error } = useGetApplicationList(); const { mutate: deleteApplication } = useDeleteApplication(); const { mutate: duplicateApplication } = useDuplicateApplication(); diff --git a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx index e748b6168..bdfc5e9c6 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/ClubInfoEditTab.tsx @@ -5,9 +5,9 @@ import Button from '@/components/common/Button/Button'; import InputField from '@/components/common/InputField/InputField'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; import { SNS_CONFIG } from '@/constants/snsConfig'; -import { useUpdateClubDetail } from '@/hooks/queries/club/useUpdateClubDetail'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useUpdateClubDetail } from '@/hooks/Queries/useClub'; import ClubCoverEditor from '@/pages/AdminPage/components/ClubCoverEditor/ClubCoverEditor'; import ClubLogoEditor from '@/pages/AdminPage/components/ClubLogoEditor/ClubLogoEditor'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; diff --git a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.tsx b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.tsx index a6f56a742..648e95678 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/MakeTags/MakeTags.tsx @@ -1,6 +1,6 @@ import deleteButton from '@/assets/images/icons/delete_button_icon.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './MakeTags.styles'; interface MakeTagsProps { diff --git a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.tsx b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.tsx index a16ec7cb3..22f121db1 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubInfoEditTab/components/SelectTags/SelectTags.tsx @@ -1,5 +1,5 @@ import { ADMIN_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './SelectTags.styles'; export interface TagOption { diff --git a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTab.tsx index 09b58222c..404eab245 100644 --- a/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/ClubIntroEditTab.tsx @@ -4,9 +4,9 @@ import { useQueryClient } from '@tanstack/react-query'; import Button from '@/components/common/Button/Button'; import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; -import { useUpdateClubDetail } from '@/hooks/queries/club/useUpdateClubDetail'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useUpdateClubDetail } from '@/hooks/Queries/useClub'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import { Award, ClubDetail, FAQ, IdealCandidate } from '@/types/club'; import * as Styled from './ClubIntroEditTab.styles'; diff --git a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx index 587b319f7..222d33278 100644 --- a/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/PhotoEditTab/PhotoEditTab.tsx @@ -3,12 +3,9 @@ import { useOutletContext } from 'react-router-dom'; import Button from '@/components/common/Button/Button'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; import { MAX_FILE_COUNT, MAX_FILE_SIZE } from '@/constants/uploadLimit'; -import { - useUpdateFeed, - useUploadFeed, -} from '@/hooks/queries/club/images/useFeedMutation'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useUpdateFeed, useUploadFeed } from '@/hooks/Queries/useClubImages'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import { ImagePreview } from '@/pages/AdminPage/tabs/PhotoEditTab/components/ImagePreview/ImagePreview'; import { ClubDetail } from '@/types/club'; @@ -36,11 +33,26 @@ const PhotoEditTab = () => { const handleFiles = (files: FileList | null) => { if (!files || files.length === 0) return; - uploadFeed({ - clubId: clubDetail.id, - files: Array.from(files), - existingUrls: imageList, - }); + uploadFeed( + { + clubId: clubDetail.id, + files: Array.from(files), + existingUrls: imageList, + }, + { + onSuccess: (data) => { + if (data.failedFiles.length > 0) { + const failedFileNames = data.failedFiles.join(', '); + alert( + `일부 파일 업로드에 실패했어요.\n실패한 파일: ${failedFileNames}\n\n성공한 파일은 정상적으로 등록되었어요.`, + ); + } + }, + onError: () => { + alert('이미지 업로드에 실패했어요. 다시 시도해주세요!'); + }, + }, + ); }; const handleUploadClick = () => { @@ -56,7 +68,6 @@ const PhotoEditTab = () => { inputRef.current?.click(); }; - /** 파일 선택 변경 */ const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; if (!files || files.length === 0) return; @@ -76,17 +87,23 @@ const PhotoEditTab = () => { handleFiles(files); }; - /** 이미지 삭제 */ const deleteImage = (index: number) => { if (isLoading) return; const newList = imageList.filter((_, i) => i !== index); setImageList(newList); - updateFeed({ - clubId: clubDetail.id, - urls: newList, - }); + updateFeed( + { + clubId: clubDetail.id, + urls: newList, + }, + { + onError: () => { + alert('이미지 삭제에 실패했어요. 다시 시도해주세요!'); + }, + }, + ); }; return ( diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts index f1e2e0e44..40ff7ce80 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts @@ -1,5 +1,5 @@ -import { colors } from '@/styles/theme/colors'; import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; export const Container = styled.div` display: flex; @@ -24,8 +24,10 @@ export const AlwaysRecruitButton = styled.button<{ $active: boolean }>` flex-shrink: 0; color: ${({ $active }) => ($active ? colors.base.white : colors.gray[700])}; - background: ${({ $active }) => ($active ? colors.primary[800] : colors.gray[300])}; - border: ${({ $active }) => ($active ? 'none' : `1px solid ${colors.gray[500]}`)}; + background: ${({ $active }) => + $active ? colors.primary[800] : colors.gray[300]}; + border: ${({ $active }) => + $active ? 'none' : `1px solid ${colors.gray[500]}`}; transition: background-color 0.12s ease, transform 0.06s ease; diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx index 9464d3883..f4395ca0d 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx @@ -5,9 +5,9 @@ import { setYear } from 'date-fns'; import Button from '@/components/common/Button/Button'; import InputField from '@/components/common/InputField/InputField'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; -import { useUpdateClubDescription } from '@/hooks/queries/club/useUpdateClubDescription'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useUpdateClubDescription } from '@/hooks/Queries/useClub'; import { ContentSection } from '@/pages/AdminPage/components/ContentSection/ContentSection'; import Calendar from '@/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar'; import { ClubDetail } from '@/types/club'; @@ -122,9 +122,6 @@ const RecruitEditTab = () => { updateClubDescription(updatedData, { onSuccess: () => { alert('모집 정보가 성공적으로 수정되었습니다.'); - queryClient.invalidateQueries({ - queryKey: ['clubDetail', clubDetail.id], - }); }, onError: (error) => { alert(`모집 정보 수정에 실패했습니다: ${error.message}`); diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.tsx index 0184ba715..57c7d097d 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.tsx +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/Calendar/Calendar.tsx @@ -3,7 +3,7 @@ import { ko } from 'date-fns/locale'; import 'react-datepicker/dist/react-datepicker.css'; import DatePicker, { ReactDatePickerCustomHeaderProps } from 'react-datepicker'; import { ADMIN_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './Calendar.styles'; interface CalendarProps { diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/MarkdownEditor/MarkdownEditor.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/MarkdownEditor/MarkdownEditor.tsx index 3e5fd33fb..e1f8bbc23 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/MarkdownEditor/MarkdownEditor.tsx +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/components/MarkdownEditor/MarkdownEditor.tsx @@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'; import eye_icon from '@/assets/images/icons/eye_icon.svg'; import pencil_icon from '@/assets/images/icons/pencil_icon_1.svg'; import { ADMIN_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './MarkdownEditor.styles'; interface MarkdownEditorProps { diff --git a/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx b/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx index c66e24194..d829969a0 100644 --- a/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx +++ b/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx @@ -1,20 +1,20 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import applyToClub from '@/apis/application/applyToClub'; +import { applyToClub } from '@/apis/application'; import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; -import { useGetApplication } from '@/hooks/queries/application/useGetApplication'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; -import { useAnswers } from '@/hooks/useAnswers'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; -import { validateAnswers } from '@/hooks/useValidateAnswers'; +import { useAnswers } from '@/hooks/Application/useAnswers'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useGetApplication } from '@/hooks/Queries/useApplication'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import QuestionAnswerer from '@/pages/ApplicationFormPage/components/QuestionAnswerer/QuestionAnswerer'; import QuestionContainer from '@/pages/ApplicationFormPage/components/QuestionContainer/QuestionContainer'; import { PageContainer } from '@/styles/PageContainer.styles'; import { Question } from '@/types/application'; import { parseDescriptionWithLinks } from '@/utils/parseDescriptionWithLinks'; +import { validateAnswers } from '@/utils/useValidateAnswers'; import * as Styled from './ApplicationFormPage.styles'; const ApplicationFormPage = () => { diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index b85e3ac93..5cfb17880 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -3,10 +3,10 @@ import { useParams, useSearchParams } from 'react-router-dom'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import useDevice from '@/hooks/useDevice'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; -import useTrackPageView from '@/hooks/useTrackPageView'; import ClubFeed from '@/pages/ClubDetailPage/components/ClubFeed/ClubFeed'; import ClubIntroContent from '@/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent'; import ClubProfileCard from '@/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard'; @@ -48,14 +48,14 @@ const ClubDetailPage = () => { trackEvent(USER_EVENT.CLUB_FEED_TAB_CLICKED); }, [setSearchParams, trackEvent]); - if (!clubDetail) { - return null; - } - if (error) { return
에러가 발생했습니다.
; } + if (!clubDetail) { + return null; + } + return ( <> {(isLaptop || isDesktop) &&
} diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts index 9c66b8d9b..11e75c495 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts @@ -17,9 +17,10 @@ export const ApplyButton = styled.button` justify-content: center; border: none; border-radius: 10px; - cursor: ${({disabled}) => (disabled ? 'default' : 'pointer')}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; transition: transform 0.2s ease-in-out; - background-color: ${({ disabled }) => disabled ? colors.gray[500] : colors.primary[800]}; + background-color: ${({ disabled }) => + disabled ? colors.gray[500] : colors.primary[800]}; padding: 10px 40px; width: 517px; @@ -40,7 +41,8 @@ export const ApplyButton = styled.button` height: 44px; font-size: 16px; font-weight: 500; - background-color: ${({ disabled }) => disabled ? colors.gray[500] : colors.gray[900]}; + background-color: ${({ disabled }) => + disabled ? colors.gray[500] : colors.gray[900]}; } `; @@ -49,4 +51,4 @@ export const Separator = styled.span` border-left: 1px solid #787878; height: 12px; display: inline-block; -`; \ No newline at end of file +`; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx index ffdf83032..e3a5a52d5 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx @@ -1,14 +1,13 @@ -import * as Styled from './ClubApplyButton.styles'; +import { useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; -import getApplication from '@/apis/application/getApplication'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import { getApplication, getApplicationOptions } from '@/apis/application'; +import ApplicationSelectModal from '@/components/application/modals/ApplicationSelectModal'; import { USER_EVENT } from '@/constants/eventName'; -import { useState } from 'react'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import { ApplicationForm, ApplicationFormMode } from '@/types/application'; -import getApplicationOptions from '@/apis/application/getApplicationOptions'; -import ApplicationSelectModal from '@/components/application/modals/ApplicationSelectModal'; import ShareButton from '../ShareButton/ShareButton'; +import * as Styled from './ClubApplyButton.styles'; interface ClubApplyButtonProps { deadlineText?: string; @@ -21,7 +20,9 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { const { data: clubDetail } = useGetClubDetail(clubId!); const [isApplicationModalOpen, setIsApplicationModalOpen] = useState(false); - const [applicationOptions, setApplicationOptions] = useState([]); + const [applicationOptions, setApplicationOptions] = useState< + ApplicationForm[] + >([]); if (!clubId || !clubDetail) return null; @@ -105,9 +106,10 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { return ( - + onClick={handleApplyButtonClick} + > {renderButtonContent()} { ); }; -export default ClubApplyButton; \ No newline at end of file +export default ClubApplyButton; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts index f32bdfd06..478b04549 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts @@ -1,5 +1,5 @@ -import { media } from '@/styles/mediaQuery'; import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; export const ClubDetailFooterContainer = styled.div` position: sticky; @@ -18,4 +18,4 @@ export const ClubDetailFooterContainer = styled.div` ${media.mobile} { padding: 10px 0px 16px 0px; } -`; \ No newline at end of file +`; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx index 52b4c3184..952ea0713 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx @@ -1,8 +1,8 @@ +import { RecruitmentStatus } from '@/types/club'; import getDeadlineText from '@/utils/getDeadLineText'; import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; import ClubApplyButton from '../ClubApplyButton/ClubApplyButton'; import * as Styled from './ClubDetailFooter.styles'; -import { RecruitmentStatus } from '@/types/club'; interface ClubDetailFooterProps { recruitmentStart: string; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubFeed/ClubFeed.tsx b/frontend/src/pages/ClubDetailPage/components/ClubFeed/ClubFeed.tsx index 65e904a8c..3dc1c0ea8 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubFeed/ClubFeed.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubFeed/ClubFeed.tsx @@ -1,4 +1,4 @@ -import { usePhotoModal } from '@/hooks/PhotoList/usePhotoModal'; +import { useEffect, useState } from 'react'; import PhotoModal from '@/pages/ClubDetailPage/components/PhotoModal/PhotoModal'; import * as Styled from './ClubFeed.styles'; @@ -8,7 +8,23 @@ interface Props { } const ClubFeed = ({ feed, clubName = '동아리' }: Props) => { - const { isOpen, index, open, close, setIndex } = usePhotoModal(); + const [isOpen, setIsOpen] = useState(false); + const [index, setIndex] = useState(0); + + const open = (i: number) => { + setIndex(i); + setIsOpen(true); + }; + const close = () => setIsOpen(false); + + useEffect(() => { + if (!feed || feed.length === 0) { + setIsOpen(false); + setIndex(0); + } else if (index >= feed.length) { + setIndex(feed.length - 1); + } + }, [feed, index]); if (!feed || feed.length === 0) { return ( diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index a3ad1ab13..93385fe02 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './ClubIntroContent.styles'; export interface Award { diff --git a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts index 330de0e5e..56d496943 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.styles.ts @@ -163,7 +163,7 @@ export const SocialText = styled.span` `; export const SocialUrl = styled.span` - color: #009CF6; + color: #009cf6; display: inline-block; max-width: 180px; overflow: hidden; @@ -174,11 +174,11 @@ export const SocialUrl = styled.span` ${media.laptop} { max-width: 100px; } - + ${media.tablet} { max-width: 180px; } - + ${media.mobile} { max-width: 120px; } diff --git a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx index 0579a8c50..e6ce40bd0 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard.tsx @@ -6,7 +6,7 @@ import DefaultCover from '@/assets/images/logos/default_cover_image.png'; import DefaultLogo from '@/assets/images/logos/default_profile_image.svg'; import ClubStateBox from '@/components/ClubStateBox/ClubStateBox'; import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { SNSPlatform } from '@/types/club'; import * as Styled from './ClubProfileCard.styles'; diff --git a/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.styles.ts b/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.styles.ts index 83be324a1..b7b30f3c6 100644 --- a/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.styles.ts @@ -200,7 +200,8 @@ export const ThumbnailList = styled.div` `; export const Thumbnail = styled.button<{ isActive: boolean }>` - border: 2px solid ${({ isActive }) => (isActive ? colors.primary[900] : 'transparent')}; + border: 2px solid + ${({ isActive }) => (isActive ? colors.primary[900] : 'transparent')}; border-radius: 6px; padding: 0; background: none; @@ -212,7 +213,8 @@ export const Thumbnail = styled.button<{ isActive: boolean }>` transition: all 0.2s; &:hover { - border-color: ${({ isActive }) => (isActive ? colors.primary[900] : '#ddd')}; + border-color: ${({ isActive }) => + isActive ? colors.primary[900] : '#ddd'}; } img { diff --git a/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.tsx b/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.tsx index 409a16014..b16a3baab 100644 --- a/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.tsx +++ b/frontend/src/pages/ClubDetailPage/components/PhotoModal/PhotoModal.tsx @@ -5,8 +5,8 @@ import { Swiper, SwiperSlide } from 'swiper/react'; import 'swiper/css/navigation'; import NextButton from '@/assets/images/icons/next_button_icon.svg'; import PrevButton from '@/assets/images/icons/prev_button_icon.svg'; -import * as Styled from './PhotoModal.styles'; import PortalModal from '@/components/common/Modal/PortalModal'; +import * as Styled from './PhotoModal.styles'; interface PhotoModalProps { isOpen: boolean; @@ -39,12 +39,8 @@ const PhotoModal = ({ isOpen, onClose, clubName, photos }: PhotoModalProps) => { if (!isOpen) return null; return ( - - e.stopPropagation()}> + + e.stopPropagation()}> {clubName} diff --git a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts index 7f4c66d22..f214210ca 100644 --- a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts @@ -1,5 +1,5 @@ -import { media } from '@/styles/mediaQuery'; import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; export const ShareButtonContainer = styled.div` display: flex; @@ -15,4 +15,4 @@ export const ShareButtonIcon = styled.img` width: 44px; height: 44px; } -`; \ No newline at end of file +`; diff --git a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx index 910561794..6733e674b 100644 --- a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx @@ -1,8 +1,8 @@ -import ShareIcon from '@/assets/images/icons/share_icon.svg'; import ShareIconMobile from '@/assets/images/icons/share_icon_mobile.svg'; +import ShareIcon from '@/assets/images/icons/share_icon.svg'; import { USER_EVENT } from '@/constants/eventName'; -import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useGetClubDetail } from '@/hooks/Queries/useClub'; import useDevice from '@/hooks/useDevice'; import * as Styled from './ShareButton.styles'; @@ -57,9 +57,9 @@ const ShareButton = ({ clubId }: ShareButtonProps) => { role='button' aria-label='카카오톡으로 동아리 정보 공유하기' > - ); diff --git a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx index 5ce54883a..0c22b6480 100644 --- a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx +++ b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx @@ -1,8 +1,8 @@ import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; -import { CLUB_UNION_MEMBERS } from '@/constants/CLUB_UNION_INFO'; +import { CLUB_UNION_MEMBERS } from '@/constants/clubUnionInfo'; import { PAGE_VIEW } from '@/constants/eventName'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import { PageContainer } from '@/styles/PageContainer.styles'; import isInAppWebView from '@/utils/isInAppWebView'; import * as Styled from './ClubUnionPage.styles'; diff --git a/frontend/src/pages/IntroducePage/IntroducePage.tsx b/frontend/src/pages/IntroducePage/IntroducePage.tsx index cb6a0d2fd..608f03c7b 100644 --- a/frontend/src/pages/IntroducePage/IntroducePage.tsx +++ b/frontend/src/pages/IntroducePage/IntroducePage.tsx @@ -1,7 +1,7 @@ import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import { PAGE_VIEW } from '@/constants/eventName'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import isInAppWebView from '@/utils/isInAppWebView'; import IntroSection from './components/sections/1.IntroSection/IntroSection'; import ProblemSection from './components/sections/2.ProblemSection/ProblemSection'; diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index aef79fe53..34e80b390 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -3,8 +3,8 @@ import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import Spinner from '@/components/common/Spinner/Spinner'; import { PAGE_VIEW } from '@/constants/eventName'; -import { useGetCardList } from '@/hooks/queries/club/useGetCardList'; -import useTrackPageView from '@/hooks/useTrackPageView'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useGetCardList } from '@/hooks/Queries/useClub'; import Banner from '@/pages/MainPage/components/Banner/Banner'; import CategoryButtonList from '@/pages/MainPage/components/CategoryButtonList/CategoryButtonList'; import ClubCard from '@/pages/MainPage/components/ClubCard/ClubCard'; diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.tsx b/frontend/src/pages/MainPage/components/Banner/Banner.tsx index 2d5bca9cd..a7ddc0cf4 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.tsx +++ b/frontend/src/pages/MainPage/components/Banner/Banner.tsx @@ -5,8 +5,8 @@ import { Swiper, SwiperSlide } from 'swiper/react'; import NextButton from '@/assets/images/icons/next_button_icon.svg'; import PrevButton from '@/assets/images/icons/prev_button_icon.svg'; import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useDevice from '@/hooks/useDevice'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import useNavigator from '@/hooks/useNavigator'; import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; import * as Styled from './Banner.styles'; diff --git a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx index cb2cf59f0..3dbff34b9 100644 --- a/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx +++ b/frontend/src/pages/MainPage/components/CategoryButtonList/CategoryButtonList.tsx @@ -3,7 +3,7 @@ import { inactiveCategoryIcons, } from '@/assets/images/icons/category_button'; import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useSelectedCategory } from '@/store/useCategoryStore'; import { useSearchStore } from '@/store/useSearchStore'; import * as Styled from './CategoryButtonList.styles'; diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index 16c66f519..8354ebee8 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -1,8 +1,8 @@ import { MouseEvent, useEffect, useState } from 'react'; import AppDownloadImage from '@/assets/images/popup/app-download.svg'; import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useDevice from '@/hooks/useDevice'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; import * as Styled from './Popup.styles'; diff --git a/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx b/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx index 8776873d0..0aa05e71e 100644 --- a/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx +++ b/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx @@ -1,6 +1,6 @@ import { useLocation, useNavigate } from 'react-router-dom'; import SearchField from '@/components/common/SearchField/SearchField'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useSelectedCategory } from '@/store/useCategoryStore'; import { useSearchInput } from '@/store/useSearchStore'; diff --git a/frontend/src/pages/MainPage/components/StatusRadioButton/StatusRadioButton.tsx b/frontend/src/pages/MainPage/components/StatusRadioButton/StatusRadioButton.tsx index dc3455bc5..2b9ef69a7 100644 --- a/frontend/src/pages/MainPage/components/StatusRadioButton/StatusRadioButton.tsx +++ b/frontend/src/pages/MainPage/components/StatusRadioButton/StatusRadioButton.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; -import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import * as Styled from './StatusRadioButton.styles'; interface StatusRadioButtonProps { diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts index de59b38ca..36a9141d8 100644 --- a/frontend/src/types/application.ts +++ b/frontend/src/types/application.ts @@ -1,4 +1,4 @@ -import { QUESTION_LABEL_MAP } from '@/constants/APPLICATION_FORM'; +import { QUESTION_LABEL_MAP } from '@/constants/applicationForm'; export type QuestionType = keyof typeof QUESTION_LABEL_MAP; diff --git a/frontend/src/types/club.responses.ts b/frontend/src/types/club.responses.ts deleted file mode 100644 index a2603c4ff..000000000 --- a/frontend/src/types/club.responses.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Club } from './club'; - -export interface ClubSearchResponse { - clubs: Club[]; - totalCount: number; -} diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts index 37f1e69b8..d48f82cff 100644 --- a/frontend/src/types/club.ts +++ b/frontend/src/types/club.ts @@ -82,3 +82,8 @@ export interface ClubApiResponse { category: string; division: string; } + +export interface ClubSearchResponse { + clubs: Club[]; + totalCount: number; +} diff --git a/frontend/src/utils/getDeadLineText.test.ts b/frontend/src/utils/getDeadLineText.test.ts index 14619aa2c..67cbd0b15 100644 --- a/frontend/src/utils/getDeadLineText.test.ts +++ b/frontend/src/utils/getDeadLineText.test.ts @@ -3,80 +3,79 @@ import getDeadlineText from './getDeadLineText'; describe('getDeadlineText 함수 테스트', () => { it.each([ [ - '오늘이 모집 종료일인 경우', + '오늘이 모집 종료일인 경우', new Date('2025-04-01'), new Date('2025-04-10'), - '2025-04-10', - 'OPEN', - 'D-Day' + '2025-04-10', + 'OPEN', + 'D-Day', ], [ - '모집 종료일까지 5일 남은 경우', + '모집 종료일까지 5일 남은 경우', new Date('2025-04-01'), new Date('2025-04-10'), '2025-04-05', 'OPEN', - 'D-5' + 'D-5', ], [ - '오늘이 모집 종료일 이후인 경우', + '오늘이 모집 종료일 이후인 경우', new Date('2025-04-01'), new Date('2025-04-10'), '2025-04-11', - 'CLOSED', - '모집 마감' + 'CLOSED', + '모집 마감', ], [ - '모집 시작일이 아직 남은 경우 (시간 포함)', + '모집 시작일이 아직 남은 경우 (시간 포함)', new Date('2025-04-01T09:00:00'), new Date('2025-04-10'), - '2025-03-30', - 'UPCOMING', - '4월 1일 09:00 모집 시작' + '2025-03-30', + 'UPCOMING', + '4월 1일 09:00 모집 시작', ], [ - '모집 시작 시간이 00:00인 경우', + '모집 시작 시간이 00:00인 경우', new Date('2025-04-01T00:00:00'), new Date('2025-04-10'), - '2025-03-30', - 'UPCOMING', - '4월 1일 모집 시작' + '2025-03-30', + 'UPCOMING', + '4월 1일 모집 시작', ], - - ])('%s', + ])( + '%s', ( - _, - recruitmentStart, - recruitmentEnd, - todayStr, - recruitmentStatus, + _, + recruitmentStart, + recruitmentEnd, + todayStr, + recruitmentStatus, expected, ) => { - const today = new Date(todayStr); - expect( - getDeadlineText( - recruitmentStart, - recruitmentEnd, - recruitmentStatus, - today - ) - ).toBe( - expected); - }); + const today = new Date(todayStr); + expect( + getDeadlineText( + recruitmentStart, + recruitmentEnd, + recruitmentStatus, + today, + ), + ).toBe(expected); + }, + ); it('모집 기간이 null인 경우 모집 마감을 반환해야 한다', () => { - expect( - getDeadlineText(null, null, 'CLOSED')).toBe('모집 마감'); + expect(getDeadlineText(null, null, 'CLOSED')).toBe('모집 마감'); }); - + it('모집 중 상태인데 모집 종료일까지 1년 이상 남으면 상시 모집을 반환해야 한다', () => { - expect( - getDeadlineText( - new Date('2025-01-01'), - new Date('2027-01-01'), - 'OPEN', - new Date('2025-01-01'), - ), - ).toBe('상시 모집'); -}); + expect( + getDeadlineText( + new Date('2025-01-01'), + new Date('2027-01-01'), + 'OPEN', + new Date('2025-01-01'), + ), + ).toBe('상시 모집'); + }); }); diff --git a/frontend/src/utils/getDeadLineText.ts b/frontend/src/utils/getDeadLineText.ts index bf8b66c89..764f8d8b7 100644 --- a/frontend/src/utils/getDeadLineText.ts +++ b/frontend/src/utils/getDeadLineText.ts @@ -1,4 +1,4 @@ -import { format, differenceInCalendarDays } from 'date-fns'; +import { differenceInCalendarDays, format } from 'date-fns'; import { ko } from 'date-fns/locale'; const RECRUITMENT_STATUS = { @@ -22,17 +22,14 @@ const getDeadlineText = ( const hour = recruitmentStart.getHours(); const minute = recruitmentStart.getMinutes(); - let formatStr = - hour === 0 && minute === 0 - ? 'M월 d일' - : 'M월 d일 HH:mm'; + let formatStr = hour === 0 && minute === 0 ? 'M월 d일' : 'M월 d일 HH:mm'; return `${format(recruitmentStart, formatStr, { locale: ko })} ${RECRUITMENT_STATUS.UPCOMING}`; } if (!recruitmentEnd) return RECRUITMENT_STATUS.CLOSED; const days = differenceInCalendarDays(recruitmentEnd, today); - if (days > 365) return RECRUITMENT_STATUS.ALWAYS; // D-day가 의미 없을 정도로 긴 경우 '상시 모집'으로 표시 + if (days > 365) return RECRUITMENT_STATUS.ALWAYS; // D-day가 의미 없을 정도로 긴 경우 '상시 모집'으로 표시 return days > 0 ? `D-${days}` : 'D-Day'; }; diff --git a/frontend/src/hooks/useValidateAnswers.ts b/frontend/src/utils/useValidateAnswers.ts similarity index 100% rename from frontend/src/hooks/useValidateAnswers.ts rename to frontend/src/utils/useValidateAnswers.ts