From 17ecea2b209763b39b486ae380e5ea77d9330818 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 24 May 2026 18:29:49 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(club-card):=20hover/touch/focus=20?= =?UTF-8?q?=EC=8B=9C=20=EB=8F=99=EC=95=84=EB=A6=AC=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?prefetch=20=EB=B0=8F=20=EC=BA=90=EC=8B=9C=20=ED=9E=88=ED=8A=B8?= =?UTF-8?q?=EC=9C=A8=20=EC=B8=A1=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - usePrefetchClubDetail 훅 추가 (prefetchQuery + hasCachedData, InAppWebView 제외) - ClubCard onMouseEnter/onTouchStart/onFocus 트리거 (모바일 prefetch 대응) - Mixpanel 이벤트 4종 추가: prefetch_triggered/hit/miss/wasted - ClubDetailPage가 동일한 @{clubName} 키로 캐시 재사용 --- frontend/src/constants/eventName.ts | 7 +++ .../hooks/Queries/usePrefetchClubDetail.ts | 39 +++++++++++++ .../pages/ClubDetailPage/ClubDetailPage.tsx | 13 ++--- .../MainPage/components/ClubCard/ClubCard.tsx | 56 +++++++++++++++++++ 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks/Queries/usePrefetchClubDetail.ts diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index c93e86326..00e55f38b 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -32,6 +32,13 @@ export const USER_EVENT = { PHOTO_NAVIGATION_CLICKED: 'Photo Navigation', CLUB_CARD_CLICKED: 'ClubCard Clicked', CLUB_CARD_VIEWED: 'ClubCard Viewed', + + // 동아리 상세 Prefetch (캐시 히트율/낭비율 측정용) + CLUB_PREFETCH_TRIGGERED: 'Club Prefetch Triggered', + CLUB_PREFETCH_HIT: 'Club Prefetch Hit', + CLUB_PREFETCH_MISS: 'Club Prefetch Miss', + CLUB_PREFETCH_WASTED: 'Club Prefetch Wasted', + SCROLL_DEPTH_REACHED: 'Scroll Depth Reached', CLUB_INTRO_TAB_CLICKED: 'Club Intro Tab Clicked', CLUB_FEED_TAB_CLICKED: 'Club Feed Tab Clicked', diff --git a/frontend/src/hooks/Queries/usePrefetchClubDetail.ts b/frontend/src/hooks/Queries/usePrefetchClubDetail.ts new file mode 100644 index 000000000..b42d70fce --- /dev/null +++ b/frontend/src/hooks/Queries/usePrefetchClubDetail.ts @@ -0,0 +1,39 @@ +import { useCallback } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { getClubDetail } from '@/apis/club'; +import { queryKeys } from '@/constants/queryKeys'; +import isInAppWebView from '@/utils/isInAppWebView'; + +// useGetClubDetail과 동일하게 유지해야 prefetch 결과가 그대로 재사용됨 +const STALE_TIME = 60 * 1000; + +const usePrefetchClubDetail = () => { + const queryClient = useQueryClient(); + + const prefetch = useCallback( + (clubParam: string): boolean => { + if (!clubParam) return false; + if (isInAppWebView()) return false; + + queryClient.prefetchQuery({ + queryKey: queryKeys.club.detail(clubParam), + queryFn: () => getClubDetail(clubParam), + staleTime: STALE_TIME, + }); + return true; + }, + [queryClient], + ); + + const hasCachedData = useCallback( + (clubParam: string): boolean => { + const state = queryClient.getQueryState(queryKeys.club.detail(clubParam)); + return state?.status === 'success' && !!state.data; + }, + [queryClient], + ); + + return { prefetch, hasCachedData }; +}; + +export default usePrefetchClubDetail; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index 052c7311c..1c522b699 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -49,9 +49,9 @@ const ClubDetailPage = () => { const { isMobile, isTablet } = useDevice(); const showTopBar = isMobile || isTablet; - const { data: clubDetail, error } = useGetClubDetail( - (clubName ?? clubId) || '', - ); + const clubParam = clubName ? `@${clubName}` : clubId || ''; + + const { data: clubDetail, error } = useGetClubDetail(clubParam); const hasCalendarConnection = clubDetail?.hasCalendarConnection ?? false; @@ -62,10 +62,9 @@ const ClubDetailPage = () => { return tabParam; }, [tabParam]); - const { data: calendarEvents = [] } = useGetClubCalendarEvents( - (clubName ?? clubId) || '', - { enabled: hasCalendarConnection && activeTab === TAB_TYPE.SCHEDULE }, - ); + const { data: calendarEvents = [] } = useGetClubCalendarEvents(clubParam, { + enabled: hasCalendarConnection && activeTab === TAB_TYPE.SCHEDULE, + }); const tabs = useMemo( () => [ diff --git a/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx b/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx index 3133c69c8..28ebd362d 100644 --- a/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx +++ b/frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx @@ -5,6 +5,7 @@ import ClubStateBox from '@/components/ClubStateBox/ClubStateBox'; import ClubTag from '@/components/ClubTag/ClubTag'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import usePrefetchClubDetail from '@/hooks/Queries/usePrefetchClubDetail'; import ClubLogo from '@/pages/MainPage/components/ClubLogo/ClubLogo'; import { Club } from '@/types/club'; import getDeviceType from '@/utils/getDeviceType'; @@ -22,6 +23,14 @@ const COOLDOWN_MS = 2_000; // IntersectionObserver jitter 방지 const IMPRESSION_THRESHOLD = 0.5; // IAB 뷰어빌리티 기준 (50% in-view) const MIN_DWELL_MS = 300; // 안구 고정 최소 시간 기반, fly-by 스크롤 제외 +const PREFETCH_TRIGGER = { + MOUSE: 'mouse', + TOUCH: 'touch', + FOCUS: 'focus', +} as const; +type PrefetchTriggerType = + (typeof PREFETCH_TRIGGER)[keyof typeof PREFETCH_TRIGGER]; + const ClubCard = ({ club, index, @@ -31,8 +40,27 @@ const ClubCard = ({ }: ClubCardProps) => { const navigate = useNavigate(); const trackEvent = useMixpanelTrack(); + const { prefetch, hasCachedData } = usePrefetchClubDetail(); const [isClicked, setIsClicked] = useState(false); const containerRef = useRef(null); + const prefetchTriggeredAtRef = useRef(null); + const wasClickedRef = useRef(false); + + const clubParam = `@${club.name}`; + + const handlePrefetch = (triggerType: PrefetchTriggerType) => { + if (prefetchTriggeredAtRef.current !== null) return; + const fired = prefetch(clubParam); + if (!fired) return; + prefetchTriggeredAtRef.current = Date.now(); + trackEvent(USER_EVENT.CLUB_PREFETCH_TRIGGERED, { + club_id: club.id, + club_name: club.name, + trigger_type: triggerType, + page, + device_type: getDeviceType(), + }); + }; const SS_LAST_KEY = `clubcard_last_${page ?? 'default'}_${club.id}`; const SS_COUNT_KEY = `clubcard_count_${page ?? 'default'}_${club.id}`; @@ -97,10 +125,20 @@ const ClubCard = ({ observer.disconnect(); document.removeEventListener('visibilitychange', handleVisibilityChange); fireImpressionEvent(); + + if (prefetchTriggeredAtRef.current !== null && !wasClickedRef.current) { + trackEvent(USER_EVENT.CLUB_PREFETCH_WASTED, { + club_id: club.id, + club_name: club.name, + page, + device_type: getDeviceType(), + }); + } }; }, [club.id, club.name, club.recruitmentStatus, page]); const handleClick = () => { + wasClickedRef.current = true; setIsClicked(true); trackEvent(USER_EVENT.CLUB_CARD_CLICKED, { club_id: club.id, @@ -116,6 +154,21 @@ const ClubCard = ({ device_type: getDeviceType(), }); + if (prefetchTriggeredAtRef.current !== null) { + const hoverToClickMs = Date.now() - prefetchTriggeredAtRef.current; + const cacheHit = hasCachedData(clubParam); + trackEvent( + cacheHit ? USER_EVENT.CLUB_PREFETCH_HIT : USER_EVENT.CLUB_PREFETCH_MISS, + { + club_id: club.id, + club_name: club.name, + hover_to_click_ms: hoverToClickMs, + page, + device_type: getDeviceType(), + }, + ); + } + setTimeout(() => { setIsClicked(false); if (onCardClick) { @@ -132,6 +185,9 @@ const ClubCard = ({ $state={club.recruitmentStatus} $isClicked={isClicked} onClick={handleClick} + onMouseEnter={() => handlePrefetch(PREFETCH_TRIGGER.MOUSE)} + onTouchStart={() => handlePrefetch(PREFETCH_TRIGGER.TOUCH)} + onFocus={() => handlePrefetch(PREFETCH_TRIGGER.FOCUS)} > From 8f3a880a7d2f288a71a03efffa2c5c40e367663e Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 24 May 2026 18:52:19 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(storybook):=20QueryClientProvider=20?= =?UTF-8?q?=EC=A0=84=EC=97=AD=20=EB=8D=B0=EC=BD=94=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClubCard에서 usePrefetchClubDetail 훅이 useQueryClient를 호출하면서 Storybook 환경에 QueryClient가 없어 Chromatic 빌드가 실패하던 문제 수정. 앞으로 React Query를 사용하는 컴포넌트 스토리도 모두 자동 지원. --- frontend/.storybook/preview.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 4c5200121..2e73be2bb 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { Preview } from '@storybook/react-vite'; import mixpanel from 'mixpanel-browser'; import { initialize, mswLoader } from 'msw-storybook-addon'; @@ -11,6 +12,12 @@ initialize({ onUnhandledRequest: 'bypass', }); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + const preview: Preview = { loaders: [mswLoader], decorators: [ @@ -30,10 +37,12 @@ const preview: Preview = { }, []); return ( - - - - + + + + + + ); }, ], From 1a58aa4a0d7c99024b4410018293f44603e377d5 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Sun, 24 May 2026 18:59:41 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20React=20import=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/.storybook/preview.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 2e73be2bb..f5669ecc7 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; import type { Preview } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import mixpanel from 'mixpanel-browser'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { ThemeProvider } from 'styled-components';