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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
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';
Expand All @@ -11,6 +12,12 @@ initialize({
onUnhandledRequest: 'bypass',
});

const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});

const preview: Preview = {
loaders: [mswLoader],
decorators: [
Expand All @@ -30,10 +37,12 @@ const preview: Preview = {
}, []);

return (
<ThemeProvider theme={theme}>
<GlobalStyles />
<Story />
</ThemeProvider>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<GlobalStyles />
<Story />
</ThemeProvider>
</QueryClientProvider>
);
},
],
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/constants/eventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/hooks/Queries/usePrefetchClubDetail.ts
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 6 additions & 7 deletions frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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(
() => [
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/pages/MainPage/components/ClubCard/ClubCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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<HTMLDivElement>(null);
const prefetchTriggeredAtRef = useRef<number | null>(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}`;
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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)}
>
<Styled.CardHeader>
<Styled.ClubProfile>
Expand Down
Loading