diff --git a/frontend/src/assets/images/icons/share_icon.svg b/frontend/src/assets/images/icons/share_icon.svg index 2a41b66ca..a1e5a0f56 100644 --- a/frontend/src/assets/images/icons/share_icon.svg +++ b/frontend/src/assets/images/icons/share_icon.svg @@ -1,4 +1,4 @@ - + diff --git a/frontend/src/assets/images/icons/share_icon_mobile.svg b/frontend/src/assets/images/icons/share_icon_mobile.svg new file mode 100644 index 000000000..8dfa0ff3f --- /dev/null +++ b/frontend/src/assets/images/icons/share_icon_mobile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 192dc08f1..6337039c9 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -83,6 +83,7 @@ const ClubDetailPage = () => { ); diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts index fa2ca81f3..9c66b8d9b 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.styles.ts @@ -1,4 +1,6 @@ import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; export const ApplyButtonContainer = styled.div` width: 100%; @@ -15,17 +17,17 @@ export const ApplyButton = styled.button` justify-content: center; border: none; border-radius: 10px; - cursor: pointer; + cursor: ${({disabled}) => (disabled ? 'default' : 'pointer')}; transition: transform 0.2s ease-in-out; - background-color: #ff7543; + background-color: ${({ disabled }) => disabled ? colors.gray[500] : colors.primary[800]}; padding: 10px 40px; width: 517px; - height: 50px; + height: 60px; font-size: 20px; font-style: normal; font-weight: 700; - color: #fff; + color: ${colors.gray[200]}; text-align: center; img { @@ -33,8 +35,12 @@ export const ApplyButton = styled.button` font-weight: 600; } - @media (max-width: 500px) { - width: 280px; + ${media.mobile} { + width: 273px; + height: 44px; + font-size: 16px; + font-weight: 500; + background-color: ${({ disabled }) => disabled ? colors.gray[500] : colors.gray[900]}; } `; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx index b7b72c630..517ce496a 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx @@ -14,25 +14,18 @@ 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([]); + const [isApplicationModalOpen, setIsApplicationModalOpen] = useState(false); + const [applicationOptions, setApplicationOptions] = useState([]); if (!clubId || !clubDetail) return null; - // 내부 폼 이동 - const goWithForm = async (formId: string) => { + const navigateToApplicationForm = async (formId: string) => { try { const formDetail = await getApplication(clubId, formId); if (formDetail?.formMode === ApplicationFormMode.EXTERNAL) { @@ -44,7 +37,7 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { } } navigate(`/application/${clubId}/${formId}`, { state: { formDetail } }); - setIsOpen(false); + setIsApplicationModalOpen(false); } catch (error) { console.error('지원서 조회 중 오류가 발생했습니다', error); alert( @@ -53,49 +46,53 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { } }; - // url 존재 시 외부, 내부 지원서 옵션에 따른 처리 - const openByOption = (option?: ApplicationForm) => { + const handleSelectApplicationOption = (option?: ApplicationForm) => { if (!option) return; - void goWithForm(option.id); + void navigateToApplicationForm(option.id); }; - const handleClick = async () => { + const handleApplyButtonClick = async () => { trackEvent(USER_EVENT.CLUB_APPLY_BUTTON_CLICKED); - if (deadlineText === RECRUITMENT_STATUS.CLOSED) { + if (isRecruitmentClosed) { alert(`현재 ${clubDetail.name} 동아리는 모집 기간이 아닙니다.`); return; } try { - const list = await getApplicationOptions(clubId); + const forms = await getApplicationOptions(clubId); - if (list.length <= 0) { + if (forms.length <= 0) { return; } - if (list.length === 1) { - await goWithForm(list[0].id); + if (forms.length === 1) { + await navigateToApplicationForm(forms[0].id); return; } - setOptions(list); - setIsOpen(true); + setApplicationOptions(forms); + setIsApplicationModalOpen(true); } catch (e) { - setOptions([]); - setIsOpen(true); + setApplicationOptions([]); + setIsApplicationModalOpen(true); console.error('지원서 옵션 조회 중 오류가 발생했습니다.', e); } }; + const recruitmentStatus = clubDetail.recruitmentStatus; + const isRecruitmentClosed = recruitmentStatus === 'CLOSED'; + const isRecruitmentUpcoming = recruitmentStatus === 'UPCOMING'; + const isAlwaysRecruiting = recruitmentStatus === 'ALWAYS'; + const renderButtonContent = () => { - if (deadlineText === RECRUITMENT_STATUS.CLOSED) { - return RECRUITMENT_STATUS.CLOSED; + if (isRecruitmentClosed || isRecruitmentUpcoming) { + return deadlineText; } return ( <> 지원하기 - {deadlineText && deadlineText !== RECRUITMENT_STATUS.ALWAYS && ( + {!isAlwaysRecruiting && deadlineText && ( <> {deadlineText} @@ -108,14 +105,16 @@ const ClubApplyButton = ({ deadlineText }: ClubApplyButtonProps) => { return ( - + {renderButtonContent()} setIsOpen(false)} - options={options} - onSelect={openByOption} + isOpen={isApplicationModalOpen} + onClose={() => setIsApplicationModalOpen(false)} + options={applicationOptions} + onSelect={handleSelectApplicationOption} /> ); diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts index a27fe5716..559644739 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.styles.ts @@ -1,11 +1,12 @@ +import { media } from '@/styles/mediaQuery'; 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; + z-index: 1040; // TODO: Portal로 모달 분리 후 header보다 낮게 재조정 + padding: 10px 0px 24px 0px; display: flex; align-items: center; @@ -14,4 +15,8 @@ export const ClubDetailFooterContainer = styled.div` background-color: white; border-top: 1px solid #cdcdcd; + + ${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 442007ff3..52b4c3184 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailFooter/ClubDetailFooter.tsx @@ -2,20 +2,23 @@ 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; recruitmentEnd: string; + recruitmentStatus: RecruitmentStatus; } const ClubDetailFooter = ({ recruitmentStart, recruitmentEnd, + recruitmentStatus, }: ClubDetailFooterProps) => { const deadlineText = getDeadlineText( recruitmentDateParser(recruitmentStart), recruitmentDateParser(recruitmentEnd), - new Date(), + recruitmentStatus, ); return ( diff --git a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts index ea18b7e0d..7f4c66d22 100644 --- a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.styles.ts @@ -1,3 +1,4 @@ +import { media } from '@/styles/mediaQuery'; import styled from 'styled-components'; export const ShareButtonContainer = styled.div` @@ -7,6 +8,11 @@ export const ShareButtonContainer = styled.div` `; export const ShareButtonIcon = styled.img` - width: 50px; - height: 50px; + width: 60px; + height: 60px; + + ${media.mobile} { + 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 ff6a7ec70..910561794 100644 --- a/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ShareButton/ShareButton.tsx @@ -1,7 +1,9 @@ import ShareIcon from '@/assets/images/icons/share_icon.svg'; +import ShareIconMobile from '@/assets/images/icons/share_icon_mobile.svg'; import { USER_EVENT } from '@/constants/eventName'; import { useGetClubDetail } from '@/hooks/queries/club/useGetClubDetail'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import useDevice from '@/hooks/useDevice'; import * as Styled from './ShareButton.styles'; interface ShareButtonProps { @@ -13,6 +15,7 @@ const DEFAULT_IMAGE_URL = 'https://avatars.githubusercontent.com/u/200371900?s=200&v=4'; const ShareButton = ({ clubId }: ShareButtonProps) => { + const { isMobile } = useDevice(); const { data: clubDetail } = useGetClubDetail(clubId); const trackEvent = useMixpanelTrack(); @@ -54,7 +57,10 @@ const ShareButton = ({ clubId }: ShareButtonProps) => { role='button' aria-label='카카오톡으로 동아리 정보 공유하기' > - + ); }; diff --git a/frontend/src/styles/Global.styles.ts b/frontend/src/styles/Global.styles.ts index 8a4bf3b66..7427302a4 100644 --- a/frontend/src/styles/Global.styles.ts +++ b/frontend/src/styles/Global.styles.ts @@ -6,7 +6,7 @@ const GlobalStyles = createGlobalStyle` padding: 0; box-sizing: border-box; } - textarea { + textarea, button, input, select { font-family: 'Pretendard', sans-serif; } body { diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts index a77ed5dba..d38e5c3be 100644 --- a/frontend/src/types/club.ts +++ b/frontend/src/types/club.ts @@ -1,12 +1,18 @@ import { SNS_CONFIG } from '@/constants/snsConfig'; +export type RecruitmentStatus = + | 'OPEN' + | 'CLOSED' + | 'UPCOMING' + | 'ALWAYS'; + export interface Club { id: string; name: string; logo: string; cover?: string; tags: string[]; - recruitmentStatus: string; + recruitmentStatus: RecruitmentStatus; division: string; category: string; introduction: string; diff --git a/frontend/src/utils/getDeadLineText.test.ts b/frontend/src/utils/getDeadLineText.test.ts index a30998e94..14619aa2c 100644 --- a/frontend/src/utils/getDeadLineText.test.ts +++ b/frontend/src/utils/getDeadLineText.test.ts @@ -1,22 +1,82 @@ import getDeadlineText from './getDeadLineText'; -const recruitmentStart = new Date('2025-04-01'); -const recruitmentEnd = new Date('2025-04-10'); - describe('getDeadlineText 함수 테스트', () => { it.each([ - ['오늘이 모집 종료일인 경우', '2025-04-10', 'D-Day'], - ['모집 종료일까지 5일 남은 경우', '2025-04-05', 'D-5'], - ['오늘이 모집 종료일 이후인 경우', '2025-04-11', '모집 마감'], - ['모집 시작일이 아직 남은 경우', '2025-03-30', '모집 전'], - ])('%s', (_, todayStr, expected) => { - const today = new Date(todayStr); - expect(getDeadlineText(recruitmentStart, recruitmentEnd, today)).toBe( + [ + '오늘이 모집 종료일인 경우', + new Date('2025-04-01'), + new Date('2025-04-10'), + '2025-04-10', + 'OPEN', + 'D-Day' + ], + [ + '모집 종료일까지 5일 남은 경우', + new Date('2025-04-01'), + new Date('2025-04-10'), + '2025-04-05', + 'OPEN', + 'D-5' + ], + [ + '오늘이 모집 종료일 이후인 경우', + new Date('2025-04-01'), + new Date('2025-04-10'), + '2025-04-11', + 'CLOSED', + '모집 마감' + ], + [ + '모집 시작일이 아직 남은 경우 (시간 포함)', + new Date('2025-04-01T09:00:00'), + new Date('2025-04-10'), + '2025-03-30', + 'UPCOMING', + '4월 1일 09:00 모집 시작' + ], + [ + '모집 시작 시간이 00:00인 경우', + new Date('2025-04-01T00:00:00'), + new Date('2025-04-10'), + '2025-03-30', + 'UPCOMING', + '4월 1일 모집 시작' + ], + + ])('%s', + ( + _, + recruitmentStart, + recruitmentEnd, + todayStr, + recruitmentStatus, expected, - ); + ) => { + const today = new Date(todayStr); + expect( + getDeadlineText( + recruitmentStart, + recruitmentEnd, + recruitmentStatus, + today + ) + ).toBe( + expected); }); it('모집 기간이 null인 경우 모집 마감을 반환해야 한다', () => { - expect(getDeadlineText(null, null)).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('상시 모집'); +}); }); diff --git a/frontend/src/utils/getDeadLineText.ts b/frontend/src/utils/getDeadLineText.ts index 9ba813293..d6a04a4ea 100644 --- a/frontend/src/utils/getDeadLineText.ts +++ b/frontend/src/utils/getDeadLineText.ts @@ -1,17 +1,40 @@ -import { differenceInCalendarDays, isAfter, isBefore } from 'date-fns'; +import { format, differenceInCalendarDays } from 'date-fns'; +import { ko } from 'date-fns/locale'; + +const RECRUITMENT_STATUS = { + CLOSED: '모집 마감', + ALWAYS: '상시 모집', + UPCOMING: '모집 시작', +}; const getDeadlineText = ( recruitmentStart: Date | null, recruitmentEnd: Date | null, + recruitmentStatus: string, today: Date = new Date(), ): string => { - if (!recruitmentStart || !recruitmentEnd) return '모집 마감'; - if (isBefore(today, recruitmentStart)) return '모집 전'; - if (isAfter(today, recruitmentEnd)) return '모집 마감'; + console.log(recruitmentStart, recruitmentEnd, recruitmentStatus, today); + + if (recruitmentStatus === 'CLOSED') { + return RECRUITMENT_STATUS.CLOSED; + } + + if (recruitmentStatus === 'UPCOMING') { + if (!recruitmentStart) return RECRUITMENT_STATUS.CLOSED; + const hour = recruitmentStart.getHours(); + const minute = recruitmentStart.getMinutes(); + + 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 '상시 모집'; + if (days > 365) return RECRUITMENT_STATUS.ALWAYS; // D-day가 의미 없을 정도로 긴 경우 '상시 모집'으로 표시 return days > 0 ? `D-${days}` : 'D-Day'; };