diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
index 3deddc210..192dc08f1 100644
--- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
+++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
@@ -11,6 +11,7 @@ import ClubFeed from '@/pages/ClubDetailPage/components/ClubFeed/ClubFeed';
import ClubIntroContent from '@/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent';
import ClubProfileCard from '@/pages/ClubDetailPage/components/ClubProfileCard/ClubProfileCard';
import * as Styled from './ClubDetailPage.styles';
+import ClubDetailFooter from './components/ClubDetailFooter/ClubDetailFooter';
const ClubDetailPage = () => {
const [activeTab, setActiveTab] = useState<'intro' | 'photos'>('intro');
@@ -79,6 +80,10 @@ const ClubDetailPage = () => {
+
>
);
};
diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts
new file mode 100644
index 000000000..fa2ca81f3
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts
@@ -0,0 +1,46 @@
+import styled from 'styled-components';
+
+export const ApplyButtonContainer = styled.div`
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ text-align: center;
+ gap: 10px;
+`;
+
+export const ApplyButton = styled.button`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: transform 0.2s ease-in-out;
+ background-color: #ff7543;
+
+ padding: 10px 40px;
+ width: 517px;
+ height: 50px;
+ font-size: 20px;
+ font-style: normal;
+ font-weight: 700;
+ color: #fff;
+ text-align: center;
+
+ img {
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ @media (max-width: 500px) {
+ width: 280px;
+ }
+`;
+
+export const Separator = styled.span`
+ margin: 0 8px;
+ 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
new file mode 100644
index 000000000..b7b72c630
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx
@@ -0,0 +1,124 @@
+import * as Styled from './ClubApplyButton.styles';
+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 { USER_EVENT } from '@/constants/eventName';
+import { useState } from 'react';
+import { ApplicationForm, ApplicationFormMode } from '@/types/application';
+import getApplicationOptions from '@/apis/application/getApplicationOptions';
+import ApplicationSelectModal from '@/components/application/modals/ApplicationSelectModal';
+import ShareButton from '../ShareButton/ShareButton';
+
+interface ClubApplyButtonProps {
+ deadlineText?: string;
+}
+
+const RECRUITMENT_STATUS = {
+ ALWAYS: '상시 모집',
+ CLOSED: '모집 마감',
+};
+
+const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => {
+ const { clubId } = useParams<{ clubId: string }>();
+ const navigate = useNavigate();
+ const trackEvent = useMixpanelTrack();
+ const { data: clubDetail } = useGetClubDetail(clubId!);
+
+ // 모달 옵션 상태
+ 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);
+ if (formDetail?.formMode === ApplicationFormMode.EXTERNAL) {
+ const externalApplicationUrl =
+ formDetail.externalApplicationUrl?.trim();
+ if (externalApplicationUrl) {
+ window.open(externalApplicationUrl, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ }
+ 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(USER_EVENT.CLUB_APPLY_BUTTON_CLICKED);
+
+ if (deadlineText === RECRUITMENT_STATUS.CLOSED) {
+ alert(`현재 ${clubDetail.name} 동아리는 모집 기간이 아닙니다.`);
+ return;
+ }
+
+ try {
+ const list = await getApplicationOptions(clubId);
+
+ if (list.length <= 0) {
+ return;
+ }
+
+ if (list.length === 1) {
+ await goWithForm(list[0].id);
+ return;
+ }
+ setOptions(list);
+ setIsOpen(true);
+ } catch (e) {
+ setOptions([]);
+ setIsOpen(true);
+ console.error('지원서 옵션 조회 중 오류가 발생했습니다.', e);
+ }
+ };
+
+ const renderButtonContent = () => {
+ if (deadlineText === RECRUITMENT_STATUS.CLOSED) {
+ return RECRUITMENT_STATUS.CLOSED;
+ }
+
+ return (
+ <>
+ 지원하기
+ {deadlineText && deadlineText !== RECRUITMENT_STATUS.ALWAYS && (
+ <>
+
+ {deadlineText}
+ >
+ )}
+ >
+ );
+ };
+
+ return (
+
+
+
+ {renderButtonContent()}
+
+ setIsOpen(false)}
+ options={options}
+ onSelect={openByOption}
+ />
+
+ );
+};
+
+export default ClubApplyButton;
\ No newline at end of file
diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts
new file mode 100644
index 000000000..a27fe5716
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts
@@ -0,0 +1,17 @@
+import styled from 'styled-components';
+
+export const ClubDetailFooterContainer = styled.div`
+ position: sticky;
+ bottom: 0;
+ width: 100%;
+ z-index: 1050; // TODO: Portal로 모달 분리 후 header보다 낮게 재조정
+ padding: 10px 40px;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+
+ background-color: white;
+ border-top: 1px solid #cdcdcd;
+`;
\ 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
new file mode 100644
index 000000000..442007ff3
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx
@@ -0,0 +1,28 @@
+import getDeadlineText from '@/utils/getDeadLineText';
+import { recruitmentDateParser } from '@/utils/recruitmentDateParser';
+import ClubApplyButton from '../ClubApplyButton/ClubApplyButton';
+import * as Styled from './ClubDetailFooter.styles';
+
+interface ClubDetailFooterProps {
+ recruitmentStart: string;
+ recruitmentEnd: string;
+}
+
+const ClubDetailFooter = ({
+ recruitmentStart,
+ recruitmentEnd,
+}: ClubDetailFooterProps) => {
+ const deadlineText = getDeadlineText(
+ recruitmentDateParser(recruitmentStart),
+ recruitmentDateParser(recruitmentEnd),
+ new Date(),
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default ClubDetailFooter;
diff --git a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts
new file mode 100644
index 000000000..ea18b7e0d
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+export const ShareButtonContainer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ cursor: pointer;
+`;
+
+export const ShareButtonIcon = styled.img`
+ width: 50px;
+ height: 50px;
+`;
\ 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
new file mode 100644
index 000000000..7cd801006
--- /dev/null
+++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx
@@ -0,0 +1,62 @@
+import * as Styled from './ShareButton.styles';
+import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail';
+import useMixpanelTrack from '@/hooks/useMixpanelTrack';
+import ShareIcon from '@/assets/images/icons/share_icon.svg';
+import { USER_EVENT } from '@/constants/eventName';
+
+interface ShareButtonProps {
+ clubId: string;
+}
+
+const MOADONG_BASE_URL = 'https://www.moadong.com/club/';
+const DEFAULT_IMAGE_URL =
+ 'https://avatars.githubusercontent.com/u/200371900?s=200&v=4';
+
+const ShareButton = ({ clubId }: ShareButtonProps) => {
+ const { data: clubDetail } = useGetClubDetail(clubId);
+ const trackEvent = useMixpanelTrack();
+
+ if (!clubDetail) return;
+
+ const handleShare = () => {
+ if (!window.Kakao || !window.Kakao.isInitialized()) {
+ alert('카카오 SDK가 아직 준비되지 않았습니다.');
+ return;
+ }
+
+ window.Kakao.Share.sendDefault({
+ objectType: 'feed',
+ content: {
+ title: clubDetail.name,
+ description: clubDetail.description,
+ imageUrl: clubDetail.logo ? clubDetail.logo : DEFAULT_IMAGE_URL,
+ link: {
+ mobileWebUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
+ webUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
+ },
+ },
+ buttons: [
+ {
+ title: '모아동에서 지원하기',
+ link: {
+ mobileWebUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
+ webUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
+ },
+ },
+ ],
+ });
+ trackEvent(USER_EVENT.SHARE_BUTTON_CLICKED, { clubName: clubDetail.name });
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default ShareButton;
\ No newline at end of file
diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts
index 6e41f36ff..a77ed5dba 100644
--- a/frontend/src/types/club.ts
+++ b/frontend/src/types/club.ts
@@ -24,8 +24,8 @@ export interface ClubDetail extends Club {
presidentPhoneNumber: string;
recruitmentForm: string;
- recruitmentStart: string | null;
- recruitmentEnd: string | null;
+ recruitmentStart: string;
+ recruitmentEnd: string;
recruitmentTarget: string;
socialLinks: Record;
diff --git a/frontend/src/utils/recruitmentDateParser.test.ts b/frontend/src/utils/recruitmentDateParser.test.ts
index 9ed8cad8e..8cfeec096 100644
--- a/frontend/src/utils/recruitmentDateParser.test.ts
+++ b/frontend/src/utils/recruitmentDateParser.test.ts
@@ -34,11 +34,14 @@ describe('모집 마감 날짜 입력 처리', () => {
);
});
- it('아무 값도 입력하지 않으면 오류가 발생한다', () => {
+ it('아무 값도 입력하지 않으면 null을 반환한다', () => {
const input = '';
- expect(() => recruitmentDateParser(input)).toThrow(
- '유효하지 않은 날짜 형식입니다. 형식은 "YYYY.MM.DD HH:mm" 이어야 합니다.',
- );
+ expect(recruitmentDateParser(input)).toBeNull();
+ });
+
+ it("'미정'을 입력하면 null을 반환한다", () => {
+ const input = '미정';
+ expect(recruitmentDateParser(input)).toBeNull();
});
it('사용자가 지정된 형식이 아닌 날짜를 입력하면 오류를 안내한다', () => {
diff --git a/frontend/src/utils/recruitmentDateParser.ts b/frontend/src/utils/recruitmentDateParser.ts
index bcdfc4fd8..0a055f486 100644
--- a/frontend/src/utils/recruitmentDateParser.ts
+++ b/frontend/src/utils/recruitmentDateParser.ts
@@ -1,6 +1,7 @@
import { isValid, parse } from 'date-fns';
-export const recruitmentDateParser = (s: string): Date => {
+export const recruitmentDateParser = (s: string): Date | null => {
+ if (s === '미정' || !s) return null;
const regex = /^\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}$/;
if (!regex.test(s)) {
throw new Error(