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