diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 000000000..168f09961 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1 @@ +frontend/public/mockServiceWorker.js \ No newline at end of file diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 19e1b19d1..9fb87dbd1 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -95,7 +95,6 @@ npm run generate:sitemap # sitemap.xml 생성 API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: - `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 -- `withErrorHandling()` - API 호출을 에러 로깅으로 래핑 - `secureFetch()` - 인증된 요청, 403 시 토큰 자동 갱신 쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index b9c997161..7627caf1f 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -1,4 +1,4 @@ - +/* eslint-disable */ /* tslint:disable */ /** diff --git a/frontend/src/constants/queryKeys.ts b/frontend/src/constants/queryKeys.ts index f6451c989..54b6f231c 100644 --- a/frontend/src/constants/queryKeys.ts +++ b/frontend/src/constants/queryKeys.ts @@ -1,4 +1,10 @@ export const queryKeys = { + googleCalendar: { + all: ['googleCalendar'] as const, + calendars: () => ['googleCalendar', 'calendars'] as const, + events: (calendarId: string, timeMin: string, timeMax: string) => + ['googleCalendar', 'events', calendarId, timeMin, timeMax] as const, + }, applicants: { all: ['clubApplicants'] as const, detail: (applicationFormId: string) => diff --git a/frontend/src/hooks/Queries/useGoogleCalendar.ts b/frontend/src/hooks/Queries/useGoogleCalendar.ts new file mode 100644 index 000000000..e1cc703e1 --- /dev/null +++ b/frontend/src/hooks/Queries/useGoogleCalendar.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + disconnectGoogleCalendar, + fetchGoogleCalendarEvents, + fetchGoogleCalendars, + selectGoogleCalendar, +} from '@/apis/calendarOAuth'; +import { queryKeys } from '@/constants/queryKeys'; +import { ApiError } from '@/errors'; +import type { GoogleCalendarListResponse } from '@/types/google'; + +export const useGetGoogleCalendars = () => { + return useQuery({ + queryKey: queryKeys.googleCalendar.calendars(), + queryFn: async () => { + try { + return await fetchGoogleCalendars(); + } catch (error) { + if (error instanceof ApiError && error.errorCode === '960-4') { + return null; + } + throw error; + } + }, + staleTime: 5 * 60 * 1000, + }); +}; + +export const useGetGoogleCalendarEvents = ( + calendarId: string, + timeMin: string, + timeMax: string, +) => { + return useQuery({ + queryKey: queryKeys.googleCalendar.events(calendarId, timeMin, timeMax), + queryFn: () => fetchGoogleCalendarEvents(calendarId, timeMin, timeMax), + staleTime: 5 * 60 * 1000, + enabled: !!calendarId, + }); +}; + +export const useSelectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + calendarId, + calendarName, + }: { + calendarId: string; + calendarName: string; + }) => selectGoogleCalendar(calendarId, calendarName), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.calendars(), + }); + }, + }); +}; + +export const useDisconnectGoogleCalendar = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: disconnectGoogleCalendar, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.googleCalendar.all, + }); + }, + }); +}; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts index 9e27d182f..c03a4d62f 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/CalendarSyncTab.styles.ts @@ -1,4 +1,6 @@ import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; export const Container = styled.div` display: flex; @@ -9,7 +11,7 @@ export const Container = styled.div` export const Description = styled.p` font-size: 0.94rem; line-height: 1.5; - color: #4b5563; + color: ${colors.gray[600]}; `; export const ConfigGrid = styled.div` @@ -17,13 +19,13 @@ export const ConfigGrid = styled.div` grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; - @media (max-width: 1024px) { + ${media.laptop} { grid-template-columns: 1fr; } `; export const Block = styled.div` - border: 1px solid #e5e7eb; + border: 1px solid ${colors.gray[300]}; border-radius: 12px; padding: 14px; `; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts index 781883d08..c054af3ce 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useGoogleCalendarData.ts @@ -1,13 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { fetchGoogleAuthorizeUrl } from '@/apis/calendarOAuth'; import { - disconnectGoogleCalendar, - fetchGoogleAuthorizeUrl, - fetchGoogleCalendarEvents, - fetchGoogleCalendars, - selectGoogleCalendar, -} from '@/apis/calendarOAuth'; -import { ApiError } from '@/errors'; -import type { GoogleCalendarEvent, GoogleCalendarItem } from '@/types/google'; + useDisconnectGoogleCalendar, + useGetGoogleCalendarEvents, + useGetGoogleCalendars, + useSelectGoogleCalendar, +} from '@/hooks/Queries/useGoogleCalendar'; import { createState } from '@/utils/calendarSyncUtils'; const GOOGLE_STATE_KEY = 'admin_calendar_sync_google_state'; @@ -25,109 +23,83 @@ export const useGoogleCalendarData = ({ onStatus, clearError, }: UseGoogleCalendarDataParams) => { - const [isGoogleConnected, setIsGoogleConnected] = useState(false); - const [googleCalendars, setGoogleCalendars] = useState( - [], + const [selectedCalendarId, setSelectedCalendarId] = useState(''); + const [isOAuthLoading, setIsOAuthLoading] = useState(false); + const hasLoadedOnce = useRef(false); + + const calendarsQuery = useGetGoogleCalendars(); + const selectMutation = useSelectGoogleCalendar(); + const disconnectMutation = useDisconnectGoogleCalendar(); + + const eventTimeRange = useMemo(() => { + const now = new Date(); + return { + timeMin: new Date(now.getFullYear(), now.getMonth() - 3, 1).toISOString(), + timeMax: new Date(now.getFullYear(), now.getMonth() + 4, 1).toISOString(), + }; + }, []); + + const eventsQuery = useGetGoogleCalendarEvents( + selectedCalendarId, + eventTimeRange.timeMin, + eventTimeRange.timeMax, ); - const [selectedCalendarId, setSelectedCalendarId] = useState(''); - const [googleCalendarEvents, setGoogleCalendarEvents] = useState< - GoogleCalendarEvent[] - >([]); - const [isGoogleLoading, setIsGoogleLoading] = useState(false); - const [isInitialChecking, setIsInitialChecking] = useState(true); - const eventLoadRequestIdRef = useRef(0); - - const loadGoogleCalendars = useCallback( - async (isInitial = false) => { - if (!isInitial) { - setIsGoogleLoading(true); - } - clearError(); - try { - const response = await fetchGoogleCalendars(); - setGoogleCalendars(response.items); - setIsGoogleConnected(true); - - // 서버가 제공한 선택값 우선, 없으면 primary, 없으면 첫 번째 - if (response.selectedCalendarId) { - setSelectedCalendarId(response.selectedCalendarId); - } else { - const primaryCalendar = response.items.find((cal) => cal.primary); - if (primaryCalendar) { - setSelectedCalendarId(primaryCalendar.id); - } else if (response.items.length > 0) { - setSelectedCalendarId(response.items[0].id); - } - } - } catch (error) { - if (error instanceof ApiError && error.errorCode === '960-4') { - setIsGoogleConnected(false); - setGoogleCalendars([]); - return; - } - if (error instanceof Error) { - onError(error.message); - } - } finally { - if (isInitial) { - setIsInitialChecking(false); - } else { - setIsGoogleLoading(false); - } - } - }, - [clearError, onError], - ); + const isGoogleConnected = calendarsQuery.data != null; + const googleCalendars = calendarsQuery.data?.items ?? []; + const googleCalendarEvents = eventsQuery.data ?? []; + const isGoogleLoading = + isOAuthLoading || selectMutation.isPending || disconnectMutation.isPending; - const loadGoogleCalendarEvents = useCallback( - async (calendarId: string) => { - if (!calendarId) { - setGoogleCalendarEvents([]); - return; + // 서버 데이터 기반 selectedCalendarId 초기화 (서버 선택값 → primary → 첫 번째) + useEffect(() => { + if (!selectedCalendarId && calendarsQuery.data) { + const { items, selectedCalendarId: serverSelected } = calendarsQuery.data; + if (serverSelected) { + setSelectedCalendarId(serverSelected); + } else { + const primary = items.find((cal) => cal.primary); + setSelectedCalendarId(primary?.id ?? items[0]?.id ?? ''); } + } + }, [calendarsQuery.data, selectedCalendarId]); - // 새 요청 ID 생성 및 저장 - const requestId = ++eventLoadRequestIdRef.current; - // 새 캘린더 로드 시작 - 이전 이벤트 즉시 제거 - setGoogleCalendarEvents([]); - clearError(); + useEffect(() => { + if (!calendarsQuery.isPending) { + hasLoadedOnce.current = true; + } + }, [calendarsQuery.isPending]); - try { - // 3개월 전부터 3개월 후까지 이벤트 조회 - const now = new Date(); - const threeMonthsAgo = new Date( - now.getFullYear(), - now.getMonth() - 3, - 1, - ); - const timeMax = new Date(now.getFullYear(), now.getMonth() + 4, 1); - - const events = await fetchGoogleCalendarEvents( - calendarId, - threeMonthsAgo.toISOString(), - timeMax.toISOString(), - ); - - // 응답이 최신 요청인지 확인 (stale response 무시) - if (requestId === eventLoadRequestIdRef.current) { - setGoogleCalendarEvents(events); - } - } catch (error) { - // 에러도 최신 요청인 경우에만 처리 - if (requestId === eventLoadRequestIdRef.current) { - if (error instanceof Error) { - onError(error.message); - } - setGoogleCalendarEvents([]); - } - } - }, - [clearError, onError], - ); + useEffect(() => { + if (calendarsQuery.error instanceof Error) { + onError(calendarsQuery.error.message); + } + }, [calendarsQuery.error, onError]); + + useEffect(() => { + if (eventsQuery.error instanceof Error) { + onError(eventsQuery.error.message); + } + }, [eventsQuery.error, onError]); + + // OAuth 콜백 처리 + useEffect(() => { + const errorMessage = sessionStorage.getItem(GOOGLE_OAUTH_ERROR_KEY); + if (errorMessage) { + onError(errorMessage); + sessionStorage.removeItem(GOOGLE_OAUTH_ERROR_KEY); + return; + } + + const successFlag = sessionStorage.getItem(GOOGLE_OAUTH_SUCCESS_KEY); + if (successFlag) { + sessionStorage.removeItem(GOOGLE_OAUTH_SUCCESS_KEY); + onStatus('Google OAuth 인증이 완료되었습니다.'); + } + }, [onError, onStatus]); const startGoogleOAuth = useCallback(async () => { - setIsGoogleLoading(true); + setIsOAuthLoading(true); clearError(); try { @@ -139,82 +111,44 @@ export const useGoogleCalendarData = ({ if (error instanceof Error) { onError(error.message); } - setIsGoogleLoading(false); + setIsOAuthLoading(false); } }, [clearError, onError]); const handleSelectCalendar = useCallback( - async (calendarId: string) => { + (calendarId: string) => { const calendar = googleCalendars.find((cal) => cal.id === calendarId); if (!calendar) return; - setIsGoogleLoading(true); clearError(); - - try { - await selectGoogleCalendar(calendarId, calendar.summary || ''); - setSelectedCalendarId(calendarId); - onStatus('캘린더가 선택되었습니다.'); - // selectedCalendarId 변경 시 useEffect에서 자동으로 이벤트 로드 - } catch (error) { - if (error instanceof Error) { - onError(error.message); - } - } finally { - setIsGoogleLoading(false); - } + selectMutation.mutate( + { calendarId, calendarName: calendar.summary || '' }, + { + onSuccess: () => { + setSelectedCalendarId(calendarId); + onStatus('캘린더가 선택되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }, + ); }, - [clearError, googleCalendars, onError, onStatus], + [clearError, googleCalendars, onError, onStatus, selectMutation], ); - const handleDisconnect = useCallback(async () => { - setIsGoogleLoading(true); + const handleDisconnect = useCallback(() => { clearError(); - - try { - await disconnectGoogleCalendar(); - // 진행 중인 모든 이벤트 로드 요청 무효화 - eventLoadRequestIdRef.current++; - setIsGoogleConnected(false); - setGoogleCalendars([]); - setSelectedCalendarId(''); - setGoogleCalendarEvents([]); - onStatus('Google Calendar 연결이 해제되었습니다.'); - } catch (error) { - if (error instanceof Error) { - onError(error.message); - } - } finally { - setIsGoogleLoading(false); - } - }, [clearError, onError, onStatus]); - - useEffect(() => { - const successFlag = sessionStorage.getItem(GOOGLE_OAUTH_SUCCESS_KEY); - const errorMessage = sessionStorage.getItem(GOOGLE_OAUTH_ERROR_KEY); - - if (errorMessage) { - onError(errorMessage); - sessionStorage.removeItem(GOOGLE_OAUTH_ERROR_KEY); - setIsInitialChecking(false); - return; - } - - if (successFlag) { - sessionStorage.removeItem(GOOGLE_OAUTH_SUCCESS_KEY); - onStatus('Google OAuth 인증이 완료되었습니다.'); - loadGoogleCalendars(true); - return; - } - - loadGoogleCalendars(true); - }, [loadGoogleCalendars, onError, onStatus]); - - useEffect(() => { - if (selectedCalendarId && isGoogleConnected) { - loadGoogleCalendarEvents(selectedCalendarId); - } - }, [selectedCalendarId, isGoogleConnected, loadGoogleCalendarEvents]); + disconnectMutation.mutate(undefined, { + onSuccess: () => { + setSelectedCalendarId(''); + onStatus('Google Calendar 연결이 해제되었습니다.'); + }, + onError: (error) => { + if (error instanceof Error) onError(error.message); + }, + }); + }, [clearError, disconnectMutation, onError, onStatus]); return { isGoogleConnected, @@ -222,11 +156,9 @@ export const useGoogleCalendarData = ({ selectedCalendarId, googleCalendarEvents, isGoogleLoading, - isInitialChecking, + isInitialChecking: calendarsQuery.isPending && !hasLoadedOnce.current, startGoogleOAuth, selectCalendar: handleSelectCalendar, disconnectGoogle: handleDisconnect, - loadGoogleCalendars, - loadGoogleCalendarEvents, }; }; diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts index 9857688f9..3c3432e53 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarData.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { fetchNotionDatabasePages, fetchNotionDatabases, @@ -30,16 +30,26 @@ export const useNotionCalendarData = ({ const [isNotionDatabaseApplying, setIsNotionDatabaseApplying] = useState(false); + const pagesRequestIdRef = useRef(0); + const applyPagesResponse = useCallback((response: NotionPagesResponse) => { setNotionItems(response.items); setNotionTotalResults(response.totalResults); - setNotionDatabaseSourceId(response.databaseId ?? ''); + const databaseId = response.databaseId ?? ''; + setNotionDatabaseSourceId(databaseId); + if (databaseId) { + setSelectedNotionDatabaseId(databaseId); + } }, []); const loadNotionPages = useCallback(async () => { + const requestId = ++pagesRequestIdRef.current; setIsNotionLoading(true); try { const response = await fetchNotionPages(); + if (requestId !== pagesRequestIdRef.current) { + return null; + } applyPagesResponse(response); return response; } catch (error: unknown) { @@ -73,10 +83,14 @@ export const useNotionCalendarData = ({ setIsNotionDatabaseApplying(true); clearError(); + const requestId = ++pagesRequestIdRef.current; fetchNotionDatabasePages({ databaseId: selectedNotionDatabaseId, }) .then((pagesResponse) => { + if (requestId !== pagesRequestIdRef.current) { + return; + } applyPagesResponse(pagesResponse); onStatus('선택한 Notion 데이터베이스를 연결했습니다.'); }) diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts index 354ed056f..5d0aa71a2 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionCalendarUiState.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { NotionSearchItem } from '@/apis/calendarOAuth'; import { buildMonthCalendarDays, @@ -22,6 +22,7 @@ export const useNotionCalendarUiState = ({ const [notionEventEnabledMap, setNotionEventEnabledMap] = useState< Record >({}); + const didInitVisibleMonthRef = useRef(false); const notionCalendarEvents = useMemo( () => @@ -62,12 +63,14 @@ export const useNotionCalendarUiState = ({ ); useEffect(() => { - if (notionCalendarEvents.length === 0) return; + if (notionCalendarEvents.length === 0 || didInitVisibleMonthRef.current) + return; const firstEventDate = dateFromKey(notionCalendarEvents[0].dateKey); setVisibleMonth( new Date(firstEventDate.getFullYear(), firstEventDate.getMonth(), 1), ); + didInitVisibleMonthRef.current = true; }, [notionCalendarEvents]); useEffect(() => { diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts index 7b764bddd..3a1be1172 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useNotionOAuth.ts @@ -64,8 +64,10 @@ export const useNotionOAuth = ({ if (!code || !state || !expectedState || state !== expectedState) { if (hasOAuthParams) { - clearOAuthParamsFromUrl(); + onError('Notion OAuth 인증 정보가 올바르지 않습니다.'); } + sessionStorage.removeItem(NOTION_STATE_KEY); + if (hasOAuthParams) clearOAuthParamsFromUrl(); return; } diff --git a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts index cbdb7598d..671c705b8 100644 --- a/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts +++ b/frontend/src/pages/AdminPage/tabs/CalendarSyncTab/hooks/useUnifiedCalendarUiState.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { GoogleCalendarEvent } from '@/apis/calendarOAuth'; import { buildMonthCalendarDays, @@ -29,6 +29,7 @@ export const useUnifiedCalendarUiState = ({ const [googleEventEnabledMap, setGoogleEventEnabledMap] = useState< Record >({}); + const didInitVisibleMonthRef = useRef(false); const unifiedNotionEvents = useMemo( () => notionCalendarEvents.map(convertNotionEventToUnified), @@ -91,7 +92,7 @@ export const useUnifiedCalendarUiState = ({ ); useEffect(() => { - if (allUnifiedEvents.length === 0) return; + if (allUnifiedEvents.length === 0 || didInitVisibleMonthRef.current) return; const lastEventDate = dateFromKey( allUnifiedEvents[allUnifiedEvents.length - 1].dateKey, @@ -99,6 +100,7 @@ export const useUnifiedCalendarUiState = ({ setVisibleMonth( new Date(lastEventDate.getFullYear(), lastEventDate.getMonth(), 1), ); + didInitVisibleMonthRef.current = true; }, [allUnifiedEvents]); useEffect(() => { diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 85b2d347a..85d617166 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; @@ -38,11 +38,6 @@ const ClubDetailPage = () => { const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab') as TabType | null; - const activeTab: TabType = - tabParam && Object.values(TAB_TYPE).includes(tabParam) - ? tabParam - : TAB_TYPE.INTRO; - const { clubId, clubName } = useParams<{ clubId: string; clubName: string; @@ -54,11 +49,31 @@ const ClubDetailPage = () => { (clubName ?? clubId) || '', ); - const hasCalendarEvents = clubDetail?.hasCalendarEvents ?? false; + const hasCalendarConnection = clubDetail?.hasCalendarConnection ?? false; + + const activeTab: TabType = useMemo(() => { + if (!tabParam || !Object.values(TAB_TYPE).includes(tabParam)) { + return TAB_TYPE.INTRO; + } + if (tabParam === TAB_TYPE.SCHEDULE && !hasCalendarConnection) { + return TAB_TYPE.INTRO; + } + return tabParam; + }, [tabParam, hasCalendarConnection]); + + useEffect(() => { + if ( + clubDetail && + tabParam === TAB_TYPE.SCHEDULE && + !hasCalendarConnection + ) { + setSearchParams({ tab: TAB_TYPE.INTRO }, { replace: true }); + } + }, [clubDetail, tabParam, hasCalendarConnection, setSearchParams]); const { data: calendarEvents = [] } = useGetClubCalendarEvents( (clubName ?? clubId) || '', - { enabled: hasCalendarEvents && activeTab === TAB_TYPE.SCHEDULE }, + { enabled: hasCalendarConnection && activeTab === TAB_TYPE.SCHEDULE }, ); const tabs = useMemo( @@ -66,11 +81,11 @@ const ClubDetailPage = () => { [ { key: TAB_TYPE.INTRO, label: '소개 내용' }, { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarEvents + hasCalendarConnection ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } : null, ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarEvents], + [hasCalendarConnection], ); const topBarTabs = useMemo( @@ -78,11 +93,11 @@ const ClubDetailPage = () => { [ { key: TAB_TYPE.INTRO, label: '소개내용' }, { key: TAB_TYPE.PHOTOS, label: '활동사진' }, - hasCalendarEvents + hasCalendarConnection ? { key: TAB_TYPE.SCHEDULE, label: '일정 보기' } : null, ].filter(Boolean) as Array<{ key: TabType; label: string }>, - [hasCalendarEvents], + [hasCalendarConnection], ); useTrackPageView( @@ -171,13 +186,16 @@ const ClubDetailPage = () => { > - {hasCalendarEvents && ( + {hasCalendarConnection && (
- +
)} diff --git a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx index 5007ff17e..a06420539 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubScheduleCalendar/ClubScheduleCalendar.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ClubCalendarEvent } from '@/types/club'; import { buildDateKeyFromDate, @@ -73,6 +73,8 @@ const formatSelectedDate = (dateKey: string) => { }; const ClubScheduleCalendar = ({ events }: ClubScheduleCalendarProps) => { + const didInitFromEventsRef = useRef(false); + const parsedEvents = useMemo(() => { const normalizedEvents = events.flatMap((event) => { const dateKey = parseDateKey(event.start); @@ -142,6 +144,14 @@ const ClubScheduleCalendar = ({ events }: ClubScheduleCalendarProps) => { return buildDateKeyFromDate(new Date()); }); + useEffect(() => { + if (didInitFromEventsRef.current || parsedEvents.length === 0) return; + const firstDate = dateFromKey(parsedEvents[0].dateKey); + setVisibleMonth(new Date(firstDate.getFullYear(), firstDate.getMonth(), 1)); + setSelectedDateKey(parsedEvents[0].dateKey); + didInitFromEventsRef.current = true; + }, [parsedEvents]); + const calendarDays = useMemo( () => buildMonthCalendarDays(visibleMonth), [visibleMonth], diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx index 690662892..bc1e2a0c0 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx @@ -10,7 +10,7 @@ const PromotionClubCTA = ({ clubName }: Props) => { const navigate = useNavigate(); const handleNavigate = () => { - navigate(`/clubDetail/@${clubName}`); + navigate(`/clubDetail/@${encodeURIComponent(clubName)}`); }; return ( diff --git a/frontend/src/pages/PromotionPage/utils/promotionNotification.ts b/frontend/src/pages/PromotionPage/utils/promotionNotification.ts index dd8c6ed14..926a4437d 100644 --- a/frontend/src/pages/PromotionPage/utils/promotionNotification.ts +++ b/frontend/src/pages/PromotionPage/utils/promotionNotification.ts @@ -5,16 +5,18 @@ export const getLatestPromotionTime = ( ): number => { if (!articles || articles.length === 0) return 0; - const timestamps = articles.map((article) => { - if (article.id && article.id.length === 24) { - const timestamp = parseInt(article.id.substring(0, 8), 16) * 1000; - if (!isNaN(timestamp)) return timestamp; - } + const timestamps = articles + .map((article) => { + if (article.id && article.id.length === 24) { + const timestamp = parseInt(article.id.substring(0, 8), 16) * 1000; + if (!isNaN(timestamp)) return timestamp; + } - return new Date(article.eventStartDate).getTime(); - }); + return new Date(article.eventStartDate).getTime(); + }) + .filter((time) => Number.isFinite(time)); - return Math.max(...timestamps); + return timestamps.length > 0 ? Math.max(...timestamps) : 0; }; export const getLastCheckedTime = (): number | null => { diff --git a/frontend/src/pages/PromotionPage/utils/sortPromotions.ts b/frontend/src/pages/PromotionPage/utils/sortPromotions.ts index 356e96d02..7a3a7296a 100644 --- a/frontend/src/pages/PromotionPage/utils/sortPromotions.ts +++ b/frontend/src/pages/PromotionPage/utils/sortPromotions.ts @@ -22,6 +22,6 @@ export const sortPromotions = ( if (aStatusWeight !== bStatusWeight) return aStatusWeight - bStatusWeight; if (aStatusWeight === 2) return aStart - bStart; if (aStatusWeight === 3) return bStart - aStart; - return 0; + return aStart - bStart; }); }; diff --git a/frontend/src/types/club.ts b/frontend/src/types/club.ts index cb24857d1..913527dc2 100644 --- a/frontend/src/types/club.ts +++ b/frontend/src/types/club.ts @@ -32,7 +32,7 @@ export interface ClubDetail extends Club { socialLinks: Record; externalApplicationUrl?: string; - hasCalendarEvents?: boolean; + hasCalendarConnection?: boolean; } export interface ClubCalendarEvent { diff --git a/frontend/src/utils/calendarSyncUtils.ts b/frontend/src/utils/calendarSyncUtils.ts index 5dfd1f968..bb508cf09 100644 --- a/frontend/src/utils/calendarSyncUtils.ts +++ b/frontend/src/utils/calendarSyncUtils.ts @@ -1,3 +1,4 @@ +import { addDays } from 'date-fns'; import type { GoogleCalendarEvent, NotionSearchItem, @@ -25,8 +26,14 @@ export const buildDefaultRedirectUri = () => `${window.location.origin}/admin/calendar-sync`; /** OAuth state용 난수 문자열을 생성한다. */ -export const createState = () => - globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); +export const createState = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +}; /** 토큰 표시용 마스킹 문자열을 만든다. */ export const maskToken = (token: string) => { @@ -118,13 +125,12 @@ export const buildMonthCalendarDays = (month: Date) => { gridEnd.setDate(monthEnd.getDate() + (6 - monthEnd.getDay())); const days: Date[] = []; - const oneDayMs = 24 * 60 * 60 * 1000; for ( - let timestamp = gridStart.getTime(); - timestamp <= gridEnd.getTime(); - timestamp += oneDayMs + let current = gridStart; + current <= gridEnd; + current = addDays(current, 1) ) { - days.push(new Date(timestamp)); + days.push(current); } return days; }; diff --git a/frontend/src/utils/formatKSTDateTime.ts b/frontend/src/utils/formatKSTDateTime.ts index 307165dcc..648e37aa1 100644 --- a/frontend/src/utils/formatKSTDateTime.ts +++ b/frontend/src/utils/formatKSTDateTime.ts @@ -4,6 +4,7 @@ export const formatKSTDateTime = ( ) => { if (!dateStr) return ''; const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return ''; const formatter = new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul',