From 7fa86abc187a7caa7429273e53bb1f7376c67967 Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(constants):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A0=A8=20=EC=83=81?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - storageKeys: ADMIN_CLUB_ID 추가 - adminFieldLimits: FAR_FUTURE_YEAR(2999) 상수화, 중복 정의 제거 - queryKeys: club.allDetails prefix 키 추가 --- frontend/src/constants/adminFieldLimits.ts | 2 ++ frontend/src/constants/queryKeys.ts | 2 ++ frontend/src/constants/storageKeys.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/frontend/src/constants/adminFieldLimits.ts b/frontend/src/constants/adminFieldLimits.ts index c63135b2a..66cd8a69b 100644 --- a/frontend/src/constants/adminFieldLimits.ts +++ b/frontend/src/constants/adminFieldLimits.ts @@ -13,6 +13,8 @@ export const FAQ_ANSWER_MAX = 300; // 모집 정보 수정 (RecruitEditTab) export const RECRUIT_TARGET_MAX = 10; +// 상시모집 종료일로 쓰는 더미 연도 +export const FAR_FUTURE_YEAR = 2999; // 계정 관리 (AccountEditTab) export const PASSWORD_MAX = 20; diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index 79a74477f..078689ff6 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -32,6 +32,8 @@ export const queryKeys = { }, club: { all: ['clubs'] as const, + /** 모든 clubDetail 쿼리를 한 번에 무효화하는 prefix */ + allDetails: ['clubDetail'] as const, detail: (clubParam: string) => ['clubDetail', clubParam] as const, calendarEvents: (clubParam: string) => ['clubCalendarEvents', clubParam] as const, diff --git a/frontend/src/constants/storageKeys.ts b/frontend/src/constants/storageKeys.ts index 9b0fe71be..e2932a20a 100644 --- a/frontend/src/constants/storageKeys.ts +++ b/frontend/src/constants/storageKeys.ts @@ -11,4 +11,6 @@ export const STORAGE_KEYS = { SATISFACTION_ANSWERED: 'satisfactionAnswered', HAS_CONSENTED_PERSONAL_INFO: 'hasConsentedPersonalInfo', QUERY_CACHE: 'MOADONG_QUERY_CACHE', + /** 관리자 로그인 시 귀속된 동아리 ID. 새로고침 후에도 관리자 UI 유지에 사용 */ + ADMIN_CLUB_ID: 'adminClubId', } as const; From 7836747d295a7aeec078deeb0404788ac9c263bc Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:46 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(store):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20clubId=20persist=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAdminClubStore에 persist 미들웨어 적용, 새로고침 후에도 관리자 UI 유지 - onRehydrateStorage에서 accessToken 부재 시 clubId 초기화 (토큰 만료 대응) - clubId 쓰기를 PrivateRoute useEffect에서 LoginTab 로그인 성공 시점으로 이동 - useLogout에서 setClubId(null) 호출로 로그아웃 시 관리자 상태 정리 - useAuth에서 불필요한 clubId 반환 제거, PrivateRoute 단순화 --- frontend/src/hooks/useAuth.ts | 7 ++---- frontend/src/hooks/useLogout.ts | 3 +++ .../AdminPage/auth/LoginTab/LoginTab.tsx | 3 +++ .../auth/PrivateRoute/PrivateRoute.tsx | 12 +--------- frontend/src/store/useAdminClubStore.ts | 22 ++++++++++++++----- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 25cc8efc2..019ffdcf4 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -4,17 +4,14 @@ import { getClubIdByToken } from '@/apis/auth'; const useAuth = () => { const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); - const [clubId, setClubId] = useState(null); useEffect(() => { const checkAuth = async () => { try { - const clubId = await getClubIdByToken(); - setClubId(clubId); + await getClubIdByToken(); setIsAuthenticated(true); } catch { setIsAuthenticated(false); - setClubId(null); } finally { setIsLoading(false); } @@ -23,7 +20,7 @@ const useAuth = () => { checkAuth(); }, []); - return { isLoading, isAuthenticated, clubId }; + return { isLoading, isAuthenticated }; }; export default useAuth; diff --git a/frontend/src/hooks/useLogout.ts b/frontend/src/hooks/useLogout.ts index 8061df78f..b378becb6 100644 --- a/frontend/src/hooks/useLogout.ts +++ b/frontend/src/hooks/useLogout.ts @@ -2,10 +2,12 @@ import { useNavigate } from 'react-router-dom'; import { logout } from '@/apis/auth'; import { ADMIN_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import { useAdminClubId } from '@/store/useAdminClubStore'; const useLogout = () => { const navigate = useNavigate(); const trackEvent = useMixpanelTrack(); + const { setClubId } = useAdminClubId(); const handleLogout = async () => { const confirmed = window.confirm('정말 로그아웃하시겠습니까?'); @@ -15,6 +17,7 @@ const useLogout = () => { await logout(); trackEvent(ADMIN_EVENT.LOGOUT_BUTTON_CLICKED); localStorage.removeItem('accessToken'); + setClubId(null); navigate('/admin/login', { replace: true }); } catch { alert('로그아웃에 실패했습니다.'); diff --git a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx index 86eef00db..57d845bae 100644 --- a/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx +++ b/frontend/src/pages/AdminPage/auth/LoginTab/LoginTab.tsx @@ -10,6 +10,7 @@ import { STORAGE_KEYS } from '@/constants/storageKeys'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; import useAuth from '@/hooks/useAuth'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import * as Styled from './LoginTab.styles'; const LoginTab = () => { @@ -22,6 +23,7 @@ const LoginTab = () => { const navigate = useNavigate(); const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { setClubId } = useAdminClubId(); useEffect(() => { if (!authLoading && isAuthenticated) { @@ -42,6 +44,7 @@ const LoginTab = () => { STORAGE_KEYS.HAS_CONSENTED_PERSONAL_INFO, JSON.stringify(loginData.allowedPersonalInformation), ); + setClubId(loginData.clubId); alert('로그인 성공! 관리자 페이지로 이동합니다.'); navigate('/admin'); } catch (error: unknown) { diff --git a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx index 5c13728d0..f086640e6 100644 --- a/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx +++ b/frontend/src/pages/AdminPage/auth/PrivateRoute/PrivateRoute.tsx @@ -1,22 +1,12 @@ -import { useEffect } from 'react'; import { Navigate } from 'react-router-dom'; import Spinner from '@/components/common/Spinner/Spinner'; import useAuth from '@/hooks/useAuth'; -import { useAdminClubId } from '@/store/useAdminClubStore'; const PrivateRoute = ({ children }: { children: React.ReactNode }) => { - const { isLoading, isAuthenticated, clubId } = useAuth(); - const { clubId: storeClubId, setClubId } = useAdminClubId(); - - useEffect(() => { - if (clubId) { - setClubId(clubId); - } - }, [clubId, setClubId]); + const { isLoading, isAuthenticated } = useAuth(); if (isLoading) return ; if (!isAuthenticated) return ; - if (clubId && storeClubId !== clubId) return ; return <>{children}; }; diff --git a/frontend/src/store/useAdminClubStore.ts b/frontend/src/store/useAdminClubStore.ts index 4f79e4a47..27ba084ce 100644 --- a/frontend/src/store/useAdminClubStore.ts +++ b/frontend/src/store/useAdminClubStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; -import { subscribeWithSelector } from 'zustand/middleware'; +import { persist } from 'zustand/middleware'; +import { STORAGE_KEYS } from '@/constants/storageKeys'; interface AdminClubStore { clubId: string | null; @@ -7,10 +8,21 @@ interface AdminClubStore { } export const useAdminClubStore = create()( - subscribeWithSelector((set) => ({ - clubId: null, - setClubId: (id) => set({ clubId: id }), - })), + persist( + (set) => ({ + clubId: null, + setClubId: (id) => set({ clubId: id }), + }), + { + name: STORAGE_KEYS.ADMIN_CLUB_ID, + partialize: (state) => ({ clubId: state.clubId }), + onRehydrateStorage: () => (state) => { + if (!localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN)) { + state?.setClubId(null); + } + }, + }, + ), ); export const useAdminClubId = () => { From 53486c389ed98571ec34dc67cb1f89a0660d0d3d Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:53 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat(ToggleButton):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?ToggleButton=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit active 상태를 받아 활성/비활성 스타일을 전환하는 범용 토글 버튼 --- .../ToggleButton/ToggleButton.styles.ts | 26 +++++++++++++++++++ .../common/ToggleButton/ToggleButton.tsx | 19 ++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 frontend/src/components/common/ToggleButton/ToggleButton.styles.ts create mode 100644 frontend/src/components/common/ToggleButton/ToggleButton.tsx diff --git a/frontend/src/components/common/ToggleButton/ToggleButton.styles.ts b/frontend/src/components/common/ToggleButton/ToggleButton.styles.ts new file mode 100644 index 000000000..6c7116f8f --- /dev/null +++ b/frontend/src/components/common/ToggleButton/ToggleButton.styles.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; + +export const Button = styled.button<{ $active: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + padding: 9px 16px; + ${setTypography(typography.button.button1)}; + cursor: pointer; + transition: + background-color 0.12s ease, + transform 0.06s ease; + + color: ${({ $active }) => ($active ? colors.base.white : colors.gray[700])}; + background-color: ${({ $active }) => + $active ? colors.primary[800] : colors.gray[300]}; + border: ${({ $active }) => + $active ? `1px solid transparent` : `1px solid ${colors.gray[500]}`}; + + &:active { + transform: translateY(1px); + } +`; diff --git a/frontend/src/components/common/ToggleButton/ToggleButton.tsx b/frontend/src/components/common/ToggleButton/ToggleButton.tsx new file mode 100644 index 000000000..218760ca6 --- /dev/null +++ b/frontend/src/components/common/ToggleButton/ToggleButton.tsx @@ -0,0 +1,19 @@ +import type { ButtonHTMLAttributes } from 'react'; +import * as Styled from './ToggleButton.styles'; + +interface ToggleButtonProps extends ButtonHTMLAttributes { + active: boolean; +} + +const ToggleButton = ({ + active, + children, + type = 'button', + ...rest +}: ToggleButtonProps) => ( + + {children} + +); + +export default ToggleButton; From 0a12c99f3fb2a93ae7f7f150b2543a5851c504d1 Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:58 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor(RecruitEditTab):=20=EC=83=81?= =?UTF-8?q?=EC=8B=9C=EB=AA=A8=EC=A7=91=20=EB=B2=84=ED=8A=BC=EC=9D=84=20Tog?= =?UTF-8?q?gleButton=EC=9C=BC=EB=A1=9C=20=EA=B5=90=EC=B2=B4=20=EB=B0=8F=20?= =?UTF-8?q?FAR=5FFUTURE=5FYEAR=20=EC=83=81=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인라인 AlwaysRecruitButton 제거, 공통 ToggleButton으로 대체 - FAR_FUTURE_YEAR import 경로를 adminFieldLimits 상수로 변경 --- .../RecruitEditTab/RecruitEditTab.styles.ts | 32 ++++--------------- .../tabs/RecruitEditTab/RecruitEditTab.tsx | 26 ++++++++------- 2 files changed, 20 insertions(+), 38 deletions(-) diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts index 477bb7791..7f39c87ce 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.styles.ts @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; export const Container = styled.div` display: flex; @@ -13,33 +13,13 @@ export const RecruitPeriodContainer = styled.div` max-width: 706px; `; -export const AlwaysRecruitButton = styled.button<{ $isAlwaysActive: boolean }>` - border-radius: 10px; - width: 120px; - height: 45px; - padding: 0px 16px; - font-weight: 600; - font-size: 1rem; - cursor: pointer; +export const AlwaysRecruitButtonWrapper = styled.div` flex-shrink: 0; - color: ${colors.gray[700]}; - background-color: ${colors.gray[300]}; - border: 1px solid ${colors.gray[500]}; - - ${({ $isAlwaysActive }) => - $isAlwaysActive && - ` - color: ${colors.base.white}; - background-color: ${colors.primary[800]}; - border: none; - `} - transition: - background-color 0.12s ease, - transform 0.06s ease; - - &:active { - transform: translateY(1px); + button { + width: 120px; + height: 45px; + ${setTypography(typography.paragraph.p2)}; } `; diff --git a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx index 1e68c7146..5ee7cf3e5 100644 --- a/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx +++ b/frontend/src/pages/AdminPage/tabs/RecruitEditTab/RecruitEditTab.tsx @@ -1,10 +1,13 @@ import { useEffect, useRef, useState } from 'react'; import { useOutletContext } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; import { setYear } from 'date-fns'; import Button from '@/components/common/Button/Button'; import InputField from '@/components/common/InputField/InputField'; -import { RECRUIT_TARGET_MAX } from '@/constants/adminFieldLimits'; +import ToggleButton from '@/components/common/ToggleButton/ToggleButton'; +import { + FAR_FUTURE_YEAR, + RECRUIT_TARGET_MAX, +} from '@/constants/adminFieldLimits'; import { ADMIN_EVENT, PAGE_VIEW } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; @@ -15,8 +18,6 @@ import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; import DateTimeRangePicker from './components/DateTimeRangePicker/DateTimeRangePicker'; import * as Styled from './RecruitEditTab.styles'; -const FAR_FUTURE_YEAR = 2999; - const RecruitEditTab = () => { const trackEvent = useMixpanelTrack(); useTrackPageView(PAGE_VIEW.RECRUITMENT_INFO_EDIT_PAGE); @@ -153,14 +154,15 @@ const RecruitEditTab = () => { onChangeRecruitmentEnd={handleEndChange} disabledEnd={isAlwaysRecruiting} /> - - 상시모집 - + + + 상시모집 + + Date: Fri, 11 Sep 2026 15:01:08 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat(ClubDetailPage):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=EC=9A=A9=20=EB=AA=A8=EC=A7=91=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B2=84=ED=8A=BC=20=EB=B0=8F=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OPEN/ALWAYS 상태의 관리자에게 지원 버튼 대신 AdminPeriodButton 노출 - AdminPeriodButton: 현재 모집 상태 표시 및 기간 변경 모달 진입점 - RecruitmentPeriodModal: 조기 마감, 기간 연장, 상시모집 전환/해제 지원 - 모달 성공 시 club.allDetails prefix로 전체 clubDetail 캐시 무효화 --- .../AdminPeriodButton.styles.ts | 86 ++++++ .../AdminPeriodButton/AdminPeriodButton.tsx | 75 ++++++ .../ClubApplyButton/ClubApplyButton.tsx | 35 ++- .../RecruitmentPeriodModal.styles.ts | 162 +++++++++++ .../RecruitmentPeriodModal.tsx | 251 ++++++++++++++++++ 5 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts create mode 100644 frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx create mode 100644 frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts create mode 100644 frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx diff --git a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts new file mode 100644 index 000000000..309f911e3 --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts @@ -0,0 +1,86 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; +import { Z_INDEX } from '@/styles/zIndex'; + +export const ButtonArea = styled.div` + position: sticky; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 10px 0 24px; + z-index: ${Z_INDEX.clubDetailFooter}; + background: ${colors.base.white}; + box-shadow: 0px 0px 14px rgba(0, 0, 0, 0.16); + + ${media.tablet} { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 500px; + padding: 10px 20px calc(20px + env(safe-area-inset-bottom)); + background: transparent; + box-shadow: none; + } + + ${media.mobile} { + left: 0; + transform: none; + max-width: 100%; + } +`; + +export const StatusInfo = styled.div` + display: flex; + align-items: center; + gap: 6px; + ${setTypography(typography.paragraph.p7)}; + color: ${colors.gray[500]}; +`; + +export const StatusDot = styled.span<{ $isAlways: boolean }>` + width: 6px; + height: 6px; + border-radius: 50%; + background: ${({ $isAlways }) => + $isAlways ? colors.secondary[3].main : colors.primary[800]}; +`; + +export const StatusText = styled.span` + color: ${colors.gray[600]}; + font-weight: 600; +`; + +export const StatusDate = styled.span` + color: ${colors.gray[500]}; + font-weight: 600; +`; + +export const ChangePeriodButton = styled.button` + width: 517px; + height: 60px; + border-radius: 14px; + border: 1.5px solid ${colors.primary[800]}; + background: ${colors.base.white}; + ${setTypography(typography.title.title5)}; + color: ${colors.primary[800]}; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease; + + &:hover { + background: ${colors.primary[500]}; + } + + ${media.tablet} { + width: 100%; + height: 50px; + ${setTypography(typography.paragraph.p2)}; + } +`; diff --git a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx new file mode 100644 index 000000000..3e4f39152 --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import Toast from '@/components/common/Toast/Toast'; +import { ClubDetail } from '@/types/club'; +import getDeadlineText from '@/utils/getDeadLineText'; +import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; +import RecruitmentPeriodModal from '../RecruitmentPeriodModal/RecruitmentPeriodModal'; +import * as Styled from './AdminPeriodButton.styles'; + +interface AdminPeriodButtonProps { + clubDetail: ClubDetail; +} + +const AdminPeriodButton = ({ clubDetail }: AdminPeriodButtonProps) => { + const [isPeriodModalOpen, setIsPeriodModalOpen] = useState(false); + const [isSuccessToastOpen, setIsSuccessToastOpen] = useState(false); + + const { recruitmentStatus } = clubDetail; + const isAlwaysRecruiting = recruitmentStatus === 'ALWAYS'; + + const deadlineText = getDeadlineText( + recruitmentDateParser(clubDetail.recruitmentStart), + recruitmentDateParser(clubDetail.recruitmentEnd), + recruitmentStatus, + ); + + const endDate = recruitmentDateParser(clubDetail.recruitmentEnd); + + const statusText = isAlwaysRecruiting + ? '상시 모집 중' + : deadlineText + ? `모집 중 · ${deadlineText}` + : '모집 중'; + + const dateRangeText = + !isAlwaysRecruiting && endDate + ? `(~ ${format(endDate, 'MM.dd', { locale: ko })} 마감)` + : null; + + return ( + <> + + + + {statusText} + {dateRangeText && ( + {dateRangeText} + )} + + setIsPeriodModalOpen(true)} + > + 지원 기간 변경 + + + + setIsPeriodModalOpen(false)} + clubDetail={clubDetail} + onSuccess={() => setIsSuccessToastOpen(true)} + /> + + setIsSuccessToastOpen(false)} + message='모집 기간이 변경됐어요.' + /> + + ); +}; + +export default AdminPeriodButton; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx index e9efd2eee..a1538a341 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubApplyButton/ClubApplyButton.tsx @@ -6,9 +6,12 @@ import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useGetClubDetail } from '@/hooks/Queries/useClub'; import useNavigator from '@/hooks/useNavigator'; +import { useAdminClubId } from '@/store/useAdminClubStore'; import { ApplicationForm, ApplicationFormMode } from '@/types/application'; +import { asClubId } from '@/types/branded'; import getDeadlineText from '@/utils/getDeadLineText'; import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; +import AdminPeriodButton from '../AdminPeriodButton/AdminPeriodButton'; import ApplicationSelectModal from '../ApplicationSelectModal/ApplicationSelectModal'; import * as Styled from './ClubApplyButton.styles'; @@ -21,6 +24,7 @@ const ClubApplyButton = () => { const handleLink = useNavigator(); const trackEvent = useMixpanelTrack(); const { data: clubDetail } = useGetClubDetail((clubName ?? clubId) || ''); + const { clubId: adminClubId } = useAdminClubId(); const [isApplicationModalOpen, setIsApplicationModalOpen] = useState(false); const [applicationOptions, setApplicationOptions] = useState< @@ -29,6 +33,26 @@ const ClubApplyButton = () => { if (!clubId || !clubDetail) return null; + const recruitmentStatus = clubDetail.recruitmentStatus; + const isRecruitmentClosed = recruitmentStatus === 'CLOSED'; + const isRecruitmentUpcoming = recruitmentStatus === 'UPCOMING'; + const isAlwaysRecruiting = recruitmentStatus === 'ALWAYS'; + + const isAdmin = + adminClubId !== null && asClubId(adminClubId) === clubDetail.id; + const canManagePeriod = + isAdmin && (recruitmentStatus === 'OPEN' || isAlwaysRecruiting); + + if (canManagePeriod) { + return ; + } + + const deadlineText = getDeadlineText( + recruitmentDateParser(clubDetail.recruitmentStart), + recruitmentDateParser(clubDetail.recruitmentEnd), + recruitmentStatus, + ); + const navigateToApplicationForm = async (formId: string) => { try { const formDetail = await getApplication(clubDetail.id, formId); @@ -87,17 +111,6 @@ const ClubApplyButton = () => { } }; - const recruitmentStatus = clubDetail.recruitmentStatus; - const isRecruitmentClosed = recruitmentStatus === 'CLOSED'; - const isRecruitmentUpcoming = recruitmentStatus === 'UPCOMING'; - const isAlwaysRecruiting = recruitmentStatus === 'ALWAYS'; - - const deadlineText = getDeadlineText( - recruitmentDateParser(clubDetail.recruitmentStart), - recruitmentDateParser(clubDetail.recruitmentEnd), - recruitmentStatus, - ); - const renderButtonContent = () => { if (isRecruitmentClosed || isRecruitmentUpcoming) { return deadlineText; diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts new file mode 100644 index 000000000..fbdfcb0c4 --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts @@ -0,0 +1,162 @@ +import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; +import { setTypography, typography } from '@/styles/theme/typography'; + +export const Dialog = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 280px; + border: 1px solid ${colors.gray[200]}; + border-radius: 14px; + background: ${colors.base.white}; + overflow: hidden; +`; + +export const Body = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 100%; + padding: 16px 24px 20px; +`; + +export const Title = styled.p` + ${setTypography(typography.title.title6)}; + color: ${colors.base.black}; + text-align: center; + letter-spacing: -0.32px; +`; + +export const PeriodDescription = styled.p` + ${setTypography(typography.paragraph.p5)}; + color: ${colors.gray[600]}; + text-align: center; + letter-spacing: -0.28px; + margin-bottom: 8px; +`; + +export const FieldsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + + & > *:last-child { + margin-top: 4px; + } +`; + +export const FieldGroup = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +`; + +export const FieldLabel = styled.span` + ${setTypography(typography.paragraph.p7)}; + color: ${colors.gray[800]}; + font-weight: 600; + letter-spacing: -0.24px; +`; + +export const InputRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const InputFieldWrapper = styled.div` + width: 64px; + flex-shrink: 0; + + input { + height: 38px; + padding: 0 8px; + text-align: center; + font-size: 14px; + min-width: unset; + + &::placeholder { + font-size: 14px; + } + } +`; + +export const InputSuffix = styled.span` + ${setTypography(typography.paragraph.p5)}; + color: ${colors.gray[700]}; + letter-spacing: -0.28px; + white-space: nowrap; +`; + +export const Preview = styled.span<{ $empty?: boolean }>` + ${setTypography(typography.paragraph.p7)}; + color: ${({ $empty }) => ($empty ? colors.gray[400] : colors.primary[800])}; + letter-spacing: -0.24px; + white-space: nowrap; +`; + +export const Divider = styled.hr` + border: none; + border-top: 1px solid ${colors.gray[200]}; + width: calc(100% + 48px); + margin: 0 -24px; +`; + +export const AlwaysToggleWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + + button { + width: 100%; + letter-spacing: -0.28px; + } +`; + +export const AlwaysToggleHint = styled.span` + ${setTypography(typography.paragraph.p7)}; + color: ${colors.gray[400]}; + text-align: center; + letter-spacing: -0.24px; +`; + +export const Footer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + height: 44px; + border-top: 1px solid ${colors.gray[200]}; +`; + +export const FooterButton = styled.button<{ $emphasized?: boolean }>` + flex: 1; + height: 100%; + padding: 12px 10px; + border: none; + background: transparent; + cursor: pointer; + letter-spacing: -0.28px; + ${({ $emphasized }) => + $emphasized + ? setTypography(typography.button.button1) + : setTypography(typography.paragraph.p5)}; + color: ${({ $emphasized }) => + $emphasized ? colors.primary[800] : colors.gray[700]}; + + &:disabled { + color: ${({ $emphasized }) => + $emphasized ? colors.primary[800] : colors.gray[700]}; + opacity: 0.35; + cursor: not-allowed; + } + + & + & { + border-left: 1px solid ${colors.gray[200]}; + } +`; diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx new file mode 100644 index 000000000..0378be7bd --- /dev/null +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx @@ -0,0 +1,251 @@ +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { addDays, format, setYear } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import InputField from '@/components/common/InputField/InputField'; +import Modal from '@/components/common/Modal/Modal'; +import ToggleButton from '@/components/common/ToggleButton/ToggleButton'; +import { FAR_FUTURE_YEAR } from '@/constants/adminFieldLimits'; +import { queryKeys } from '@/constants/queryKeys'; +import { useUpdateClubDescription } from '@/hooks/Queries/useClub'; +import { ClubDetail } from '@/types/club'; +import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; +import * as Styled from './RecruitmentPeriodModal.styles'; + +const formatPreview = (date: Date) => format(date, 'M월 d일', { locale: ko }); + +const formatShort = (date: Date | null) => + date ? format(date, 'MM.dd', { locale: ko }) : '?'; + +interface RecruitmentPeriodModalProps { + isOpen: boolean; + onClose: () => void; + clubDetail: ClubDetail; + onSuccess: () => void; +} + +const RecruitmentPeriodModal = ({ + isOpen, + onClose, + clubDetail, + onSuccess, +}: RecruitmentPeriodModalProps) => { + const [earlyCloseDays, setEarlyCloseDays] = useState(''); + const [extendDays, setExtendDays] = useState(''); + const [switchToAlways, setSwitchToAlways] = useState(false); + + const queryClient = useQueryClient(); + const { mutate: updateClubDescription, isPending } = + useUpdateClubDescription(); + + const isAlways = clubDetail.recruitmentStatus === 'ALWAYS'; + const currentEnd = recruitmentDateParser(clubDetail.recruitmentEnd); + const currentStart = recruitmentDateParser(clubDetail.recruitmentStart); + + const earlyCloseNum = parseInt(earlyCloseDays, 10); + const extendNum = parseInt(extendDays, 10); + + const validEarlyClose = + earlyCloseDays !== '' && !isNaN(earlyCloseNum) && earlyCloseNum > 0; + const validExtend = + extendDays !== '' && !isNaN(extendNum) && extendNum > 0 && !!currentEnd; + + const exitAlwaysCanSubmit = isAlways && switchToAlways; + const canSubmit = + (validEarlyClose || + validExtend || + (!isAlways && switchToAlways) || + exitAlwaysCanSubmit) && + !isPending; + + const today = new Date(); + const earlyClosePreview = validEarlyClose + ? addDays(today, earlyCloseNum) + : null; + const extendPreview = + validExtend && currentEnd ? addDays(currentEnd, extendNum) : null; + + const exitAlwaysDefaultDate = + isAlways && switchToAlways && !validEarlyClose ? currentStart : null; + + const handleEarlyCloseDaysChange = (value: string) => { + if (value !== '' && !/^\d+$/.test(value)) return; + setEarlyCloseDays(value); + if (isAlways) { + setSwitchToAlways(value !== ''); + } else { + setExtendDays(''); + setSwitchToAlways(false); + } + }; + + const handleExtendDaysChange = (value: string) => { + if (value !== '' && !/^\d+$/.test(value)) return; + setExtendDays(value); + setEarlyCloseDays(''); + setSwitchToAlways(false); + }; + + const handleToggleAlways = () => { + setSwitchToAlways((prev) => { + const next = !prev; + if (!isAlways && next) { + setEarlyCloseDays(''); + setExtendDays(''); + } + return next; + }); + }; + + const handleClose = () => { + setEarlyCloseDays(''); + setExtendDays(''); + setSwitchToAlways(false); + onClose(); + }; + + const handleConfirm = () => { + let newEnd: Date; + + if (isAlways && switchToAlways) { + newEnd = validEarlyClose + ? addDays(today, earlyCloseNum) + : (currentStart ?? today); + } else if (!isAlways && switchToAlways) { + newEnd = setYear(currentStart ?? today, FAR_FUTURE_YEAR); + } else if (validEarlyClose) { + newEnd = addDays(today, earlyCloseNum); + } else if (validExtend && currentEnd) { + newEnd = addDays(currentEnd, extendNum); + } else { + return; + } + + updateClubDescription( + { + id: clubDetail.id, + recruitmentStart: currentStart?.toISOString() ?? null, + recruitmentEnd: newEnd.toISOString(), + recruitmentTarget: clubDetail.recruitmentTarget, + }, + { + onSuccess: () => { + // 훅 내부 invalidate는 clubDetail.id 기반이라 clubName slug로 캐싱된 + // ClubDetailPage 쿼리를 갱신하지 못한다. prefix로 전체 무효화한다. + void queryClient.invalidateQueries({ + queryKey: queryKeys.club.allDetails, + }); + handleClose(); + onSuccess(); + }, + }, + ); + }; + + const currentPeriodText = isAlways + ? '상시 모집 중' + : `${formatShort(currentStart)} ~ ${formatShort(currentEnd)}`; + + const earlyCloseDisabled = !isAlways && switchToAlways; + + return ( + + + + 지원 기간 변경 + + 현재 모집 기간: {currentPeriodText} + + + + + + + 조기 마감 + + + handleEarlyCloseDaysChange(e.target.value)} + disabled={earlyCloseDisabled} + /> + + 일 뒤 마감 + + {earlyClosePreview + ? `→ ${formatPreview(earlyClosePreview)} 마감` + : '→ 날짜 미리보기'} + + + + + {!isAlways && ( + + 기간 연장 + + + handleExtendDaysChange(e.target.value)} + disabled={switchToAlways} + /> + + 일 연장 + + {extendPreview + ? `→ ${formatPreview(extendPreview)}까지 연장` + : '→ 날짜 미리보기'} + + + + )} + + + + {isAlways ? '상시 모집 해제' : '상시 모집으로 전환'} + + {isAlways && + switchToAlways && + !validEarlyClose && + exitAlwaysDefaultDate && ( + + 일수 미입력 시 {formatPreview(exitAlwaysDefaultDate)}{' '} + 기준으로 마감 설정 + + )} + + + + + + + 취소 + + + 확인 + + + + + ); +}; + +export default RecruitmentPeriodModal; From bf5e410c174889895d7a3647d0b8c89999487b81 Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:14:52 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix(useClub):=20=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EB=B3=80=EA=B2=BD=20=ED=9B=84=20=ED=99=88?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=20=EC=A6=89=EC=8B=9C=20=EB=AC=B4=ED=9A=A8=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useUpdateClubDescription onSuccess에서 club.detail(id) 대신 club.allDetails prefix로 전체 상세 쿼리를, club.all prefix로 목록 쿼리도 함께 무효화한다. - slug 기반 ClubDetailPage 쿼리가 UUID 기반 invalidate를 빗나가던 버그 수정 - 홈 목록에서 모집 상태 변경이 즉시 반영되지 않던 버그 수정 - 훅이 무효화를 전담하므로 RecruitmentPeriodModal의 중복 invalidate 제거 --- frontend/src/hooks/Queries/useClub.ts | 7 +++++-- .../RecruitmentPeriodModal/RecruitmentPeriodModal.tsx | 8 -------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/Queries/useClub.ts b/frontend/src/hooks/Queries/useClub.ts index 221243bd8..ce69eb22f 100644 --- a/frontend/src/hooks/Queries/useClub.ts +++ b/frontend/src/hooks/Queries/useClub.ts @@ -122,9 +122,12 @@ export const useUpdateClubDescription = () => { return useMutation({ mutationFn: (updatedData: ClubDescription) => updateClubDescription(updatedData), - onSuccess: (_, variables) => { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.club.allDetails, + }); queryClient.invalidateQueries({ - queryKey: queryKeys.club.detail(variables.id), + queryKey: queryKeys.club.all, }); }, onError: (error) => { diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx index 0378be7bd..d63bd1f61 100644 --- a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx @@ -1,12 +1,10 @@ import { useState } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; import { addDays, format, setYear } from 'date-fns'; import { ko } from 'date-fns/locale'; import InputField from '@/components/common/InputField/InputField'; import Modal from '@/components/common/Modal/Modal'; import ToggleButton from '@/components/common/ToggleButton/ToggleButton'; import { FAR_FUTURE_YEAR } from '@/constants/adminFieldLimits'; -import { queryKeys } from '@/constants/queryKeys'; import { useUpdateClubDescription } from '@/hooks/Queries/useClub'; import { ClubDetail } from '@/types/club'; import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; @@ -34,7 +32,6 @@ const RecruitmentPeriodModal = ({ const [extendDays, setExtendDays] = useState(''); const [switchToAlways, setSwitchToAlways] = useState(false); - const queryClient = useQueryClient(); const { mutate: updateClubDescription, isPending } = useUpdateClubDescription(); @@ -130,11 +127,6 @@ const RecruitmentPeriodModal = ({ }, { onSuccess: () => { - // 훅 내부 invalidate는 clubDetail.id 기반이라 clubName slug로 캐싱된 - // ClubDetailPage 쿼리를 갱신하지 못한다. prefix로 전체 무효화한다. - void queryClient.invalidateQueries({ - queryKey: queryKeys.club.allDetails, - }); handleClose(); onSuccess(); }, From 407eba99e91b4b8faf594f97f303ebd95e60936b Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:14:13 +0900 Subject: [PATCH 7/9] =?UTF-8?q?style(AdminPeriodButton):=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=C2=B7=EB=AA=A8=EB=8B=AC=20=ED=83=80=EC=9D=B4=ED=8B=80?= =?UTF-8?q?=20'=EB=AA=A8=EC=A7=91=20=EA=B8=B0=EA=B0=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD'=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F?= =?UTF-8?q?=20dot=20=EC=83=89=EC=83=81=20=EC=A3=BC=ED=99=A9=20=EB=8B=A8?= =?UTF-8?q?=EC=9D=BC=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 버튼 텍스트, aria-label, 모달 타이틀을 '모집 기간 변경'으로 변경 - StatusDot $isAlways prop 제거, 모든 상태 primary[800](주황) 단일 색상 --- .../components/AdminPeriodButton/AdminPeriodButton.styles.ts | 5 ++--- .../components/AdminPeriodButton/AdminPeriodButton.tsx | 4 ++-- .../RecruitmentPeriodModal/RecruitmentPeriodModal.tsx | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts index 309f911e3..fbc4966e4 100644 --- a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.styles.ts @@ -43,12 +43,11 @@ export const StatusInfo = styled.div` color: ${colors.gray[500]}; `; -export const StatusDot = styled.span<{ $isAlways: boolean }>` +export const StatusDot = styled.span` width: 6px; height: 6px; border-radius: 50%; - background: ${({ $isAlways }) => - $isAlways ? colors.secondary[3].main : colors.primary[800]}; + background: ${colors.primary[800]}; `; export const StatusText = styled.span` diff --git a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx index 3e4f39152..a534bc025 100644 --- a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx @@ -42,7 +42,7 @@ const AdminPeriodButton = ({ clubDetail }: AdminPeriodButtonProps) => { <> - + {statusText} {dateRangeText && ( {dateRangeText} @@ -52,7 +52,7 @@ const AdminPeriodButton = ({ clubDetail }: AdminPeriodButtonProps) => { type='button' onClick={() => setIsPeriodModalOpen(true)} > - 지원 기간 변경 + 모집 기간 변경 diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx index d63bd1f61..3ce4a533e 100644 --- a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx @@ -145,10 +145,10 @@ const RecruitmentPeriodModal = ({ - 지원 기간 변경 + 모집 기간 변경 현재 모집 기간: {currentPeriodText} From eb88e4aa46059a816aa918bd312de29f2f3081d2 Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:56:17 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat(club-detail):=20=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EB=B3=80=EA=B2=BD=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20Mixpanel=20=ED=8A=B8=EB=9E=98=ED=82=B9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/eventName.ts | 4 +++ .../AdminPeriodButton/AdminPeriodButton.tsx | 10 ++++++- .../RecruitmentPeriodModal.tsx | 26 ++++++++++++++++--- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 13e901bfe..55390d63b 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -173,6 +173,10 @@ export const ADMIN_EVENT = { PASSWORD_CHANGE_BUTTON_CLICKED: '비밀번호 변경 버튼클릭', NEW_PASSWORD_CLEAR_BUTTON_CLICKED: '새 비밀번호 입력 초기화 버튼클릭', CONFIRM_PASSWORD_CLEAR_BUTTON_CLICKED: '확인 비밀번호 입력 초기화 버튼클릭', + + // 동아리 상세 - 모집 기간 변경 (관리자 전용) + PERIOD_CHANGE_BUTTON_CLICKED: '모집 기간 변경 버튼클릭', + PERIOD_CHANGE_CONFIRMED: '모집 기간 변경 완료', } as const; export const PAGE_VIEW = { diff --git a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx index a534bc025..80068cdb3 100644 --- a/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx +++ b/frontend/src/pages/ClubDetailPage/components/AdminPeriodButton/AdminPeriodButton.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { format } from 'date-fns'; import { ko } from 'date-fns/locale'; import Toast from '@/components/common/Toast/Toast'; +import { ADMIN_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { ClubDetail } from '@/types/club'; import getDeadlineText from '@/utils/getDeadLineText'; import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; @@ -13,6 +15,7 @@ interface AdminPeriodButtonProps { } const AdminPeriodButton = ({ clubDetail }: AdminPeriodButtonProps) => { + const trackEvent = useMixpanelTrack(); const [isPeriodModalOpen, setIsPeriodModalOpen] = useState(false); const [isSuccessToastOpen, setIsSuccessToastOpen] = useState(false); @@ -50,7 +53,12 @@ const AdminPeriodButton = ({ clubDetail }: AdminPeriodButtonProps) => { setIsPeriodModalOpen(true)} + onClick={() => { + trackEvent(ADMIN_EVENT.PERIOD_CHANGE_BUTTON_CLICKED, { + clubId: clubDetail.id, + }); + setIsPeriodModalOpen(true); + }} > 모집 기간 변경 diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx index 3ce4a533e..a3011854b 100644 --- a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx @@ -5,6 +5,8 @@ import InputField from '@/components/common/InputField/InputField'; import Modal from '@/components/common/Modal/Modal'; import ToggleButton from '@/components/common/ToggleButton/ToggleButton'; import { FAR_FUTURE_YEAR } from '@/constants/adminFieldLimits'; +import { ADMIN_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useUpdateClubDescription } from '@/hooks/Queries/useClub'; import { ClubDetail } from '@/types/club'; import { recruitmentDateParser } from '@/utils/recruitmentDateParser'; @@ -32,6 +34,7 @@ const RecruitmentPeriodModal = ({ const [extendDays, setExtendDays] = useState(''); const [switchToAlways, setSwitchToAlways] = useState(false); + const trackEvent = useMixpanelTrack(); const { mutate: updateClubDescription, isPending } = useUpdateClubDescription(); @@ -103,16 +106,27 @@ const RecruitmentPeriodModal = ({ const handleConfirm = () => { let newEnd: Date; + let actionType: 'earlyClose' | 'extend' | 'switchToAlways' | 'exitAlways'; + let days: number | null = null; if (isAlways && switchToAlways) { - newEnd = validEarlyClose - ? addDays(today, earlyCloseNum) - : (currentStart ?? today); + actionType = 'exitAlways'; + if (validEarlyClose) { + newEnd = addDays(today, earlyCloseNum); + days = earlyCloseNum; + } else { + newEnd = currentStart ?? today; + } } else if (!isAlways && switchToAlways) { + actionType = 'switchToAlways'; newEnd = setYear(currentStart ?? today, FAR_FUTURE_YEAR); } else if (validEarlyClose) { + actionType = 'earlyClose'; + days = earlyCloseNum; newEnd = addDays(today, earlyCloseNum); } else if (validExtend && currentEnd) { + actionType = 'extend'; + days = extendNum; newEnd = addDays(currentEnd, extendNum); } else { return; @@ -127,6 +141,12 @@ const RecruitmentPeriodModal = ({ }, { onSuccess: () => { + trackEvent(ADMIN_EVENT.PERIOD_CHANGE_CONFIRMED, { + clubId: clubDetail.id, + actionType, + days, + previousStatus: clubDetail.recruitmentStatus, + }); handleClose(); onSuccess(); }, From 96c2c219439ec909fe64cac3b35b0fcfcca55b0e Mon Sep 17 00:00:00 2001 From: suhyun113 <163711629+suhyun113@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:04:53 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix(club-detail):=20=EB=AA=A8=EC=A7=91=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=EB=B3=80=EA=B2=BD=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=95=84=EB=93=9C=20=EB=84=88=EB=B9=84=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20=EC=B5=9C=EB=8C=80=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9D=BC=20365=EC=9D=BC=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/adminFieldLimits.ts | 3 +++ .../RecruitmentPeriodModal.styles.ts | 11 ++++++++--- .../RecruitmentPeriodModal/RecruitmentPeriodModal.tsx | 7 ++++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/adminFieldLimits.ts b/frontend/src/constants/adminFieldLimits.ts index 66cd8a69b..0e3057cb7 100644 --- a/frontend/src/constants/adminFieldLimits.ts +++ b/frontend/src/constants/adminFieldLimits.ts @@ -16,5 +16,8 @@ export const RECRUIT_TARGET_MAX = 10; // 상시모집 종료일로 쓰는 더미 연도 export const FAR_FUTURE_YEAR = 2999; +// 모집 기간 변경 모달 (RecruitmentPeriodModal) +export const PERIOD_CHANGE_DAYS_MAX = 365; + // 계정 관리 (AccountEditTab) export const PASSWORD_MAX = 20; diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts index fbdfcb0c4..ed19eee81 100644 --- a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.styles.ts @@ -69,15 +69,16 @@ export const InputRow = styled.div` `; export const InputFieldWrapper = styled.div` - width: 64px; - flex-shrink: 0; + flex: 0 0 64px; + max-width: 64px; + overflow: hidden; input { height: 38px; padding: 0 8px; text-align: center; font-size: 14px; - min-width: unset; + min-width: 0; &::placeholder { font-size: 14px; @@ -97,6 +98,10 @@ export const Preview = styled.span<{ $empty?: boolean }>` color: ${({ $empty }) => ($empty ? colors.gray[400] : colors.primary[800])}; letter-spacing: -0.24px; white-space: nowrap; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; `; export const Divider = styled.hr` diff --git a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx index a3011854b..98247231d 100644 --- a/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx +++ b/frontend/src/pages/ClubDetailPage/components/RecruitmentPeriodModal/RecruitmentPeriodModal.tsx @@ -4,7 +4,10 @@ import { ko } from 'date-fns/locale'; import InputField from '@/components/common/InputField/InputField'; import Modal from '@/components/common/Modal/Modal'; import ToggleButton from '@/components/common/ToggleButton/ToggleButton'; -import { FAR_FUTURE_YEAR } from '@/constants/adminFieldLimits'; +import { + FAR_FUTURE_YEAR, + PERIOD_CHANGE_DAYS_MAX, +} from '@/constants/adminFieldLimits'; import { ADMIN_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useUpdateClubDescription } from '@/hooks/Queries/useClub'; @@ -70,6 +73,7 @@ const RecruitmentPeriodModal = ({ const handleEarlyCloseDaysChange = (value: string) => { if (value !== '' && !/^\d+$/.test(value)) return; + if (value !== '' && parseInt(value, 10) > PERIOD_CHANGE_DAYS_MAX) return; setEarlyCloseDays(value); if (isAlways) { setSwitchToAlways(value !== ''); @@ -81,6 +85,7 @@ const RecruitmentPeriodModal = ({ const handleExtendDaysChange = (value: string) => { if (value !== '' && !/^\d+$/.test(value)) return; + if (value !== '' && parseInt(value, 10) > PERIOD_CHANGE_DAYS_MAX) return; setExtendDays(value); setEarlyCloseDays(''); setSwitchToAlways(false);