Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -79,6 +80,10 @@ const ClubDetailPage = () => {
</Styled.ContentWrapper>
</Styled.Container>
<Footer />
<ClubDetailFooter
recruitmentStart={clubDetail.recruitmentStart}
recruitmentEnd={clubDetail.recruitmentEnd}
/>
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
`;
Original file line number Diff line number Diff line change
@@ -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<ApplicationForm[]>([]);

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 && (
<>
<Styled.Separator />
{deadlineText}
</>
)}
</>
);
};

return (
<Styled.ApplyButtonContainer>
<ShareButton clubId={clubId} />
<Styled.ApplyButton onClick={handleClick}>
{renderButtonContent()}
</Styled.ApplyButton>
<ApplicationSelectModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
options={options}
onSelect={openByOption}
/>
</Styled.ApplyButtonContainer>
);
};

export default ClubApplyButton;
Original file line number Diff line number Diff line change
@@ -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;
`;
Original file line number Diff line number Diff line change
@@ -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(),
);
Comment on lines +15 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

날짜 파싱 에러 처리를 추가하세요.

recruitmentDateParser는 잘못된 날짜 형식에 대해 에러를 throw하는데, 현재 에러 처리가 없어 컴포넌트가 크래시할 수 있습니다. 예를 들어, recruitmentStartrecruitmentEnd가 빈 문자열이나 '미정'이 아니면서 잘못된 형식(예: "2025-05-25")일 경우 에러가 발생합니다.

사용자 경험 개선을 위해 try-catch로 에러를 처리하고 적절한 fallback UI를 표시하거나, 최소한 에러 로깅을 추가하는 것을 권장합니다.

🔎 에러 처리 추가 예시
 const ClubDetailFooter = ({
   recruitmentStart,
   recruitmentEnd,
 }: ClubDetailFooterProps) => {
+  let deadlineText: string;
+  try {
-  const deadlineText = getDeadlineText(
-    recruitmentDateParser(recruitmentStart),
-    recruitmentDateParser(recruitmentEnd),
-    new Date(),
-  );
+    deadlineText = getDeadlineText(
+      recruitmentDateParser(recruitmentStart),
+      recruitmentDateParser(recruitmentEnd),
+      new Date(),
+    );
+  } catch (error) {
+    console.error('모집 기간 파싱 오류:', error);
+    deadlineText = '모집 기간 확인 불가';
+  }

   return (
     <Styled.ClubDetailFooterContainer>
       <ClubApplyButton deadlineText={deadlineText} />
     </Styled.ClubDetailFooterContainer>
   );
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const deadlineText = getDeadlineText(
recruitmentDateParser(recruitmentStart),
recruitmentDateParser(recruitmentEnd),
new Date(),
);
const ClubDetailFooter = ({
recruitmentStart,
recruitmentEnd,
}: ClubDetailFooterProps) => {
let deadlineText: string;
try {
deadlineText = getDeadlineText(
recruitmentDateParser(recruitmentStart),
recruitmentDateParser(recruitmentEnd),
new Date(),
);
} catch (error) {
console.error('모집 기간 파싱 오류:', error);
deadlineText = '모집 기간 확인 불가';
}
return (
<Styled.ClubDetailFooterContainer>
<ClubApplyButton deadlineText={deadlineText} />
</Styled.ClubDetailFooterContainer>
);
};
🤖 Prompt for AI Agents
In
frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx
around lines 15-19, the calls to recruitmentDateParser can throw on invalid
input and currently crash the component; wrap the parsing and getDeadlineText
call in a try-catch, log the error (console.error or a logger) and provide a
safe fallback (e.g., nulls or a "날짜 정보 없음" deadlineText) so the component
renders without breaking; ensure getDeadlineText is only called with valid Date
objects and the fallback path renders appropriate UI or hides the deadline
section.


return (
<Styled.ClubDetailFooterContainer>
<ClubApplyButton deadlineText={deadlineText} />
</Styled.ClubDetailFooterContainer>
);
};

export default ClubDetailFooter;
Original file line number Diff line number Diff line change
@@ -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;
`;
Original file line number Diff line number Diff line change
@@ -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}`,
},
},
Comment on lines +27 to +37
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

카카오 공유 description에 잘못된 타입이 전달되고 있습니다.

Line 31에서 clubDetail.description을 카카오 공유 description으로 사용하고 있지만, ClubDetail 타입의 descriptionDetailedDescription 객체입니다(문자열이 아님). 이로 인해 카카오톡 공유 시 [object Object]가 표시되거나 런타임 에러가 발생할 수 있습니다.

clubDetail.introduction 또는 clubDetail.description.introDescription을 사용해야 합니다.

🔎 수정 제안
     window.Kakao.Share.sendDefault({
       objectType: 'feed',
       content: {
         title: clubDetail.name,
-        description: clubDetail.description,
+        description: clubDetail.introduction,
         imageUrl: clubDetail.logo ? clubDetail.logo : DEFAULT_IMAGE_URL,
         link: {
           mobileWebUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
           webUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
         },
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}`,
},
},
window.Kakao.Share.sendDefault({
objectType: 'feed',
content: {
title: clubDetail.name,
description: clubDetail.introduction,
imageUrl: clubDetail.logo ? clubDetail.logo : DEFAULT_IMAGE_URL,
link: {
mobileWebUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
webUrl: `${MOADONG_BASE_URL}${clubDetail.id}`,
},
},
🤖 Prompt for AI Agents
In frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx
around lines 27 to 37, the Kakao share payload uses clubDetail.description which
is a DetailedDescription object, causing “[object Object]” or runtime errors;
replace it with a string field such as clubDetail.introduction or
clubDetail.description.introDescription (prefer introduction if available), and
ensure you coerce to a string with a safe fallback (e.g., empty string or
DEFAULT_DESCRIPTION) so the Kakao description always receives a plain string.

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 (
<Styled.ShareButtonContainer
onClick={handleShare}
role='button'
aria-label='카카오톡으로 동아리 정보 공유하기'
>
<Styled.ShareButtonIcon src={ShareIcon} alt='카카오톡 공유' />
</Styled.ShareButtonContainer>
);
};

export default ShareButton;
4 changes: 2 additions & 2 deletions frontend/src/types/club.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SNSPlatform, string>;
Expand Down
11 changes: 7 additions & 4 deletions frontend/src/utils/recruitmentDateParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('사용자가 지정된 형식이 아닌 날짜를 입력하면 오류를 안내한다', () => {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/utils/recruitmentDateParser.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down