diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ef5af5fe9..3025dd911 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -77,7 +77,7 @@ const App = () => { } /> } /> } /> diff --git a/frontend/src/apis/application/getApplication.ts b/frontend/src/apis/application/getApplication.ts index 6598ce899..ffda83da9 100644 --- a/frontend/src/apis/application/getApplication.ts +++ b/frontend/src/apis/application/getApplication.ts @@ -1,11 +1,16 @@ import API_BASE_URL from '@/constants/api'; -const getApplication = async (clubId: string) => { +const getApplication = async (clubId: string, applicationFormId: string) => { try { - const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply`); + const response = await fetch(`${API_BASE_URL}/api/club/${clubId}/apply/${applicationFormId}`); if (!response.ok) { - console.error(`Failed to fetch: ${response.statusText}`); - throw new Error((await response.json()).message); + 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(); diff --git a/frontend/src/apis/application/getApplicationOptions.ts b/frontend/src/apis/application/getApplicationOptions.ts new file mode 100644 index 000000000..cd9ed2baa --- /dev/null +++ b/frontend/src/apis/application/getApplicationOptions.ts @@ -0,0 +1,28 @@ +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; \ No newline at end of file diff --git a/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts b/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts new file mode 100644 index 000000000..59d8fc345 --- /dev/null +++ b/frontend/src/components/application/modals/ApplicationSelectModal.styles.ts @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +export const EmptyMessage = styled.div` + padding: 16px 8px; + color: #9D9D9D; + text-align: center; + font-weight: 600; +`; + +export const List = styled.div` + display: grid; + gap: 16px; +`; + +export const OptionButton = styled.button` + width: 100%; + padding: 18px 20px; + border-radius: 10px; + border: 1px solid #DCDCDC; + background: #fff; + font-weight: 600; + font-size: 16px; + cursor: pointer; + transition: background-color .15s ease, color .15s ease, border-color .15s ease; + + &:hover { + background: #ff7a00; + color: #fff; + border-color: #ff7a00; + } +`; diff --git a/frontend/src/components/application/modals/ApplicationSelectModal.tsx b/frontend/src/components/application/modals/ApplicationSelectModal.tsx new file mode 100644 index 000000000..8950f058e --- /dev/null +++ b/frontend/src/components/application/modals/ApplicationSelectModal.tsx @@ -0,0 +1,52 @@ +import Modal from "@/components/common/Modal/Modal"; +import * as Styled from './ApplicationSelectModal.styles'; +import { ApplicationForm } from "@/types/application"; + +export interface ApplicationSelectModalProps { + isOpen: boolean; + onClose: () => void; + options: ApplicationForm[]; + onSelect: (option: ApplicationForm) => void; + onBackdropClick?: () => boolean | void; +} + +interface OptionsListProps { + options: ApplicationForm[]; + onSelect: (option: ApplicationForm) => void; +} + +const OptionsList = ({ options, onSelect }: OptionsListProps) => { + if (options.length === 0) { + return 지원 가능한 분야가 없습니다.; + } + + return ( + + {options.map((option) => ( + {onSelect(option);}}> + {option.title} + + ))} + + ) +}; + +const ApplicationSelectModal = ({ isOpen, onClose, options, onSelect, onBackdropClick }: ApplicationSelectModalProps) => { + const handleOverlayClick = () => { + return false; + }; + + return ( + + + + ); +}; + +export default ApplicationSelectModal; diff --git a/frontend/src/components/common/Modal/Modal.styles.ts b/frontend/src/components/common/Modal/Modal.styles.ts new file mode 100644 index 000000000..455b90623 --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.styles.ts @@ -0,0 +1,60 @@ +import { media } from '@/styles/mediaQuery'; +import styled from 'styled-components'; + +export const Overlay = styled.div<{ isOpen: boolean }>` + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0,0,0, ${({ isOpen }) => (isOpen ? 0.45 : 0)}); + display: grid; + place-items: center; + padding: 24px; + transition: background-color .2s ease; +`; + +export const Container = styled.div<{ isOpen: boolean }>` + max-width: 500px; + width: 100%; + max-height: 90vh; + background: #fff; + border-radius: 10px; + overflow: hidden; + box-shadow: ${({ isOpen }) => (isOpen ? '0 18px 44px rgba(0,0,0,.22)' : 'none')}; + transition: transform .2s ease, box-shadow .2s ease; +`; + +export const Header = styled.div` + padding: 30px; + border-bottom: 1px solid #DCDCDC; + display: flex; + align-items: center; +`; + +export const Title = styled.h3` + font-size: 20px; + font-weight: 800; + flex: 1; + text-align: left; +`; + +export const IconButton = styled.button` + border: none; + background: transparent; + font-size: 20px; + font-weight: 800; + color: #9D9D9D; + line-height: 1; + cursor: pointer; +`; + +export const Description = styled.p` + padding: 20px 32px 0px; + text-align: left; + color: #9D9D9D; + font-weight: 600; +`; + +export const Body = styled.div` + padding: 16px 30px 30px; + overflow: auto; +`; diff --git a/frontend/src/components/common/Modal/Modal.tsx b/frontend/src/components/common/Modal/Modal.tsx new file mode 100644 index 000000000..9723424fe --- /dev/null +++ b/frontend/src/components/common/Modal/Modal.tsx @@ -0,0 +1,40 @@ +import { MouseEvent, ReactNode, useEffect } from "react"; +import * as Styled from './Modal.styles'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + description?: string; + children?: ReactNode; + onBackdropClick?: () => boolean | void; +} + + +const Modal = ({ isOpen, onClose, title, description, children, onBackdropClick }: ModalProps) => { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + if (!isOpen) return null; + + return ( + + ) => e.stopPropagation()}> + + {title && {title}} + + + {description && {description}} + {children} + + + ); +} + +export default Modal; \ No newline at end of file diff --git a/frontend/src/context/AdminClubContext.tsx b/frontend/src/context/AdminClubContext.tsx index aaf87f2cc..3c9c40449 100644 --- a/frontend/src/context/AdminClubContext.tsx +++ b/frontend/src/context/AdminClubContext.tsx @@ -6,6 +6,8 @@ interface AdminClubContextType { setClubId: (id: string | null) => void; applicantsData: ApplicantsInfo | null; setApplicantsData: (data: ApplicantsInfo | null) => void; + applicationFormId: string | null; + setApplicationFormId: (id: string | null) => void; } const AdminClubContext = createContext( @@ -19,9 +21,16 @@ export const AdminClubProvider = ({ }) => { const [clubId, setClubId] = useState(null); const [applicantsData, setApplicantsData] = useState(null); + const [applicationFormId, setApplicationFormId] = useState(null); return ( - + {children} ); diff --git a/frontend/src/hooks/queries/application/useGetApplication.ts b/frontend/src/hooks/queries/application/useGetApplication.ts index c376b9f7f..81f98bebf 100644 --- a/frontend/src/hooks/queries/application/useGetApplication.ts +++ b/frontend/src/hooks/queries/application/useGetApplication.ts @@ -1,11 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import getApplication from '@/apis/application/getApplication'; -export const useGetApplication = (clubId: string) => { +export const useGetApplication = (clubId?: string | null, applicationFormId?: string | null) => { return useQuery({ - queryKey: ['applicationForm', clubId], - queryFn: () => getApplication(clubId), + queryKey: ['applicationForm', clubId, applicationFormId], + queryFn: () => getApplication(clubId!, applicationFormId!), retry: false, - enabled: !!clubId, + enabled: !!clubId && !!applicationFormId, }); }; diff --git a/frontend/src/mocks/api/apply.ts b/frontend/src/mocks/api/apply.ts index 56f6a4eee..7e080e685 100644 --- a/frontend/src/mocks/api/apply.ts +++ b/frontend/src/mocks/api/apply.ts @@ -1,5 +1,5 @@ import { http, HttpResponse } from 'msw'; -import { mockData } from '../data/mockData'; +import { mockData, mockOptions } from '../data/mockData'; import { API_BASE } from '../constants/clubApi'; import { validateClubId } from '../utils/validateClubId'; import { ERROR_MESSAGE } from '../constants/error'; @@ -24,9 +24,14 @@ export const applyHandlers = [ return HttpResponse.json( { - clubId, - form_title: mockData.title, - questions: mockData.questions, + status: '200', + message: 'OK', + data: { + clubId, + title: mockData.title, + description: mockData.description, + questions: mockData.questions, + }, }, { status: 200 }, ); @@ -69,4 +74,16 @@ export const applyHandlers = [ { status: 200 }, ); }), + + http.get(`${API_BASE}/:clubId/applications`, ({ params }) => { + const clubId = String(params.clubId); + if (!validateClubId(clubId)) { + return HttpResponse.json( + { message: ERROR_MESSAGE.INVALID_CLUB_ID }, + { status: 400 }, + ); + } + const list = mockOptions[clubId] ?? []; + return HttpResponse.json({data: list}, {status: 200}); + }), ]; diff --git a/frontend/src/mocks/constants/clubApi.ts b/frontend/src/mocks/constants/clubApi.ts index 99a936f02..9c6d54d6f 100644 --- a/frontend/src/mocks/constants/clubApi.ts +++ b/frontend/src/mocks/constants/clubApi.ts @@ -1,3 +1,8 @@ export const API_BASE = 'http://localhost/api/club'; export const CLUB_ID = '67e54ae51cfd27718dd40be6'; + +export const CLUB_IVF = '67ee2ca3b35e3c267e3c248d'; +export const CLUB_BOB = '67e54ae51cfd27718dd40bea'; +export const CLUB_BACK = '67e54ae51cfd27718dd40bd8'; +export const CLUB_TEST = '67ebf9f75c8623081055881c'; \ No newline at end of file diff --git a/frontend/src/mocks/data/mockData.ts b/frontend/src/mocks/data/mockData.ts index 3a246039b..73b1c72f3 100644 --- a/frontend/src/mocks/data/mockData.ts +++ b/frontend/src/mocks/data/mockData.ts @@ -1,4 +1,5 @@ -import { ApplicationFormData } from '@/types/application'; +import { ApplicationFormData, ApplicationForm } from '@/types/application'; +import { CLUB_BACK, CLUB_BOB, CLUB_IVF, CLUB_TEST } from '../constants/clubApi'; type QuestionType = | 'CHOICE' @@ -121,3 +122,22 @@ export const mockData: ApplicationFormData = { }, ], }; + +export const mockOptions: Record = { + /*보블리스*/ + [CLUB_BOB]: [ + { id: 'string101', title: '개발자로 지원하기' }, + { id: 'string103', title: '기획자로 지원하기' }, + ], + /*IVF*/ + [CLUB_IVF]: [ + { id: 'string201', title: '선수로 지원하기' }, + { id: 'string202', title: '매니저로 지원하기' }, + ], + /*백경예술연구회*/ + [CLUB_BACK]: [ + { id: 'string301', title: '백경예술연구회 지원하기' }, + ], + /*테스트*/ + [CLUB_TEST]: [], +}; \ No newline at end of file diff --git a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx index 405358c5d..aeaf8f846 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx @@ -38,9 +38,9 @@ const ApplicantDetailPage = () => { const [applicantStatus, setApplicantStatus] = useState( ApplicationStatus.SUBMITTED, ); - const { applicantsData, clubId } = useAdminClubContext(); + const { applicantsData, clubId, applicationFormId } = useAdminClubContext(); - const { data: formData, isLoading, isError } = useGetApplication(clubId!); + const { data: formData, isLoading, isError } = useGetApplication(clubId!, applicationFormId!); const { mutate: updateApplicant } = useUpdateApplicant(clubId!); const applicantIndex = @@ -75,7 +75,7 @@ const ApplicantDetailPage = () => { }, ]); }, 400), - [clubId, questionId], + [clubId, questionId, updateApplicant], ); if (!applicantsData) { diff --git a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx index bc5476820..29a7da418 100644 --- a/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/ApplicationEditTab/ApplicationEditTab.tsx @@ -15,13 +15,9 @@ import CustomTextArea from '@/components/common/CustomTextArea/CustomTextArea'; import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM'; const ApplicationEditTab = () => { - const { clubId } = useAdminClubContext(); - if (!clubId) return null; - - const { data, isLoading, isError } = useGetApplication(clubId); - - const [formData, setFormData] = - useState(INITIAL_FORM_DATA); + const { clubId, applicationFormId } = useAdminClubContext(); + const { data, isLoading, isError } = useGetApplication(clubId, applicationFormId); + const [formData, setFormData] = useState(INITIAL_FORM_DATA); useEffect(() => { if (data) { @@ -36,6 +32,8 @@ const ApplicationEditTab = () => { return maxId + 1; }); + if (!clubId || !applicationFormId) return null; + const addQuestion = () => { const newQuestion: Question = { id: nextId, diff --git a/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx b/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx index 43a03a438..51c40fb52 100644 --- a/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx +++ b/frontend/src/pages/ApplicationFormPage/ApplicationFormPage.tsx @@ -18,28 +18,28 @@ import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { EVENT_NAME } from '@/constants/eventName'; const ApplicationFormPage = () => { - const { clubId } = useParams<{ clubId: string }>(); + const { clubId, applicationFormId } = useParams<{ clubId: string; applicationFormId: string }>(); const navigate = useNavigate(); const questionRefs = useRef>([]); const [invalidQuestionIds, setInvalidQuestionIds] = useState([]); const trackEvent = useMixpanelTrack(); - const { data: clubDetail, error: clubError } = useGetClubDetail(clubId!); + if (!clubId || !applicationFormId) return null; + + const { data: clubDetail, error: clubError } = useGetClubDetail(clubId); const { data: formData, isLoading, isError, error: applicationError, - } = useGetApplication(clubId!); + } = useGetApplication(clubId, applicationFormId); useTrackPageView( 'ApplicationFormPage', clubDetail?.name ?? `club:${clubId ?? 'unknown'}`, ); - if (!clubId) return null; - - const STORAGE_KEY = `applicationAnswers_${clubId}`; + const STORAGE_KEY = `applicationAnswers_${clubId}_${applicationFormId}`; const saved = localStorage.getItem(STORAGE_KEY); const initialAnswers = saved ? JSON.parse(saved) : []; @@ -51,7 +51,7 @@ const ApplicationFormPage = () => { useEffect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(answers)); - }, [answers]); + }, [answers, clubId, applicationFormId]); if (isLoading) return ; if (isError || clubError) { @@ -104,12 +104,12 @@ const ApplicationFormPage = () => { await applyToClub(clubId, answers); localStorage.removeItem(STORAGE_KEY); alert( - `"${clubDetail.name}" 동아리에 성공적으로 지원되었습니다.\n좋은 결과 있으시길 바랍니다🤗`, + `"${clubDetail.name}" 동아리에 성공적으로 지원되었습니다.\n좋은 결과 있으시길 바랍니다`, ); navigate(`/club/${clubId}`, { replace: true }); } catch (error) { alert( - '⚠️ 답변 제출에 실패했어요.\n네트워크 상태를 확인하거나 잠시 후 다시 시도해 주세요.', + '답변 제출에 실패했어요.\n네트워크 상태를 확인하거나 잠시 후 다시 시도해 주세요.', ); } }; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx index 4916b7024..65ffb1f82 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx @@ -5,6 +5,10 @@ import getApplication from '@/apis/application/getApplication'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { EVENT_NAME } from '@/constants/eventName'; import ShareButton from '@/pages/ClubDetailPage/components/ShareButton/ShareButton'; +import { useState } from 'react'; +import { ApplicationForm } from '@/types/application'; +import getApplicationOptions from '@/apis/application/getApplicationOptions'; +import ApplicationSelectModal from '@/components/application/modals/ApplicationSelectModal'; interface ClubApplyButtonProps { deadlineText?: string; @@ -19,10 +23,32 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { const { clubId } = useParams<{ clubId: string }>(); const navigate = useNavigate(); const trackEvent = useMixpanelTrack(); - const { data: clubDetail } = useGetClubDetail(clubId!); - if (!clubId || !clubDetail) return; + // 모달 옵션 상태 + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState([]); + + if (!clubId || !clubDetail) return null; + + + // 내부 폼 이동 + const goWithForm = async (formId: string) => { + try { + const formDetail = await getApplication(clubId, formId); + navigate(`/application/${clubId}/${formId}`, { state: { formDetail } }); + setIsOpen(false); + } catch (error) { + console.error('지원서 조회 중 오류가 발생했습니다', error); + alert('지원서 정보를 불러오는 중 오류가 발생했습니다. 다시 시도해주세요.'); + } + }; + + // url 존재 시 외부, 내부 지원서 옵션에 따른 처리 + const openByOption = (option?: ApplicationForm) => { + if (!option) return; + void goWithForm(option.id); + }; const handleClick = async () => { trackEvent(EVENT_NAME.CLUB_APPLY_BUTTON_CLICKED); @@ -33,18 +59,29 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { } try { - await getApplication(clubId); - navigate(`/application/${clubId}`); - } catch (err: unknown) { - const externalFormLink = clubDetail.externalApplicationUrl?.trim(); + const list = await getApplicationOptions(clubId); - if (!externalFormLink) { - alert('동아리 모집 정보를 확인해주세요.'); + if (list.length <= 0) { + return; + } + + if (list.length === 1) { + await goWithForm(list[0].id); return; } - window.open(externalFormLink, '_blank', 'noopener,noreferrer'); + setOptions(list); + setIsOpen(true); + } catch (e) { + const externalApplicationUrl = clubDetail.externalApplicationUrl?.trim(); + if (externalApplicationUrl) { + window.open(externalApplicationUrl, '_blank'); + return; + } + setOptions([]); + setIsOpen(true); + console.error('지원서 옵션 조회 중 오류가 발생했습니다.', e); } - }; + }; const renderButtonContent = () => { if (deadlineText === RECRUITMENT_STATUS.CLOSED) { @@ -70,6 +107,12 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { {renderButtonContent()} + setIsOpen(false)} + options={options} + onSelect={openByOption} + /> ); }; diff --git a/frontend/src/types/application.ts b/frontend/src/types/application.ts index 710beea93..d4e30bdbe 100644 --- a/frontend/src/types/application.ts +++ b/frontend/src/types/application.ts @@ -56,3 +56,8 @@ export interface AnswerItem { id: number; value: string; } + +export interface ApplicationForm { + id: string; + title: string; +} \ No newline at end of file