diff --git a/src/components/insight/DropOffAnalysisSection.tsx b/src/components/insight/DropOffAnalysisSection.tsx index 8da63e1d..addc4ff3 100644 --- a/src/components/insight/DropOffAnalysisSection.tsx +++ b/src/components/insight/DropOffAnalysisSection.tsx @@ -1,4 +1,4 @@ -import type { DropOffSlide, DropOffTime } from '@/types/insight'; +import type { DropOffSlide, DropOffTime } from '@/types/insight'; import SlideThumb from './SlideThumb'; @@ -16,13 +16,20 @@ export default function DropOffAnalysisSection({ showVideoDropOff = true, }: DropOffAnalysisSectionProps) { const isSlideOnly = !showVideoDropOff; // !hasVideo 일때 + const noDataMessage = '데이터를 분석 중이거나 결과가 없습니다.'; + const hasSlideDropOff = dropOffSlides.some((item) => item.count > 0); + const hasVideoDropOff = dropOffTimes.some((item) => item.count > 0); return (
{/* 슬라이드 이탈 */}

가장 많이 이탈한 슬라이드

- {isSlideOnly ? ( + {!hasSlideDropOff ? ( +
+

{noDataMessage}

+
+ ) : isSlideOnly ? ( // !hasVideo UI
{dropOffSlides.map((item, idx) => ( @@ -74,29 +81,35 @@ export default function DropOffAnalysisSection({ {showVideoDropOff && (

가장 많이 이탈한 영상 구간

- {dropOffTimes.map((item, idx) => ( -
- -
- {item.time} - {item.desc} -
-
- {item.count}명 - 이탈 -
+ {!hasVideoDropOff ? ( +
+

{noDataMessage}

- ))} + ) : ( + dropOffTimes.map((item, idx) => ( +
+ +
+ {item.time} + {item.desc} +
+
+ {item.count}명 + 이탈 +
+
+ )) + )}
)}
diff --git a/src/components/insight/RecentCommentsSection.tsx b/src/components/insight/RecentCommentsSection.tsx new file mode 100644 index 00000000..b6a03b90 --- /dev/null +++ b/src/components/insight/RecentCommentsSection.tsx @@ -0,0 +1,59 @@ +// src/components/insight/RecentCommentsSection.tsx +import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto'; +import { RecentCommentItem } from '@/components/insight'; +import { formatVideoTimestamp } from '@/utils/format'; + +const thumbBase = 'bg-gray-100 rounded-lg aspect-video'; + +export function RecentCommentsSection({ + hasVideo, + recentCommentsData, +}: { + hasVideo: boolean; + recentCommentsData?: ReadRecentCommentListResponseDto; +}) { + return ( +
+
+
+

최근 댓글 피드백

+ + {recentCommentsData?.comments && recentCommentsData.comments.length > 0 ? ( + recentCommentsData.comments.map((comment) => ( + + )) + ) : ( +
+ 아직 등록된 댓글이 없습니다. +
+ )} +
+ + {!hasVideo && ( +
+
+

+ 영상을 녹화하면 더 자세한 분석을 받을 수 있어요 +

+
    +
  • • 시청 구간별 이탈률 분석
  • +
  • • 영상 잔존율 그래프
  • +
  • • 타임라인 기반 댓글 피드백
  • +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/insight/charts/RetentionChartCard.tsx b/src/components/insight/charts/RetentionChartCard.tsx new file mode 100644 index 00000000..eb224851 --- /dev/null +++ b/src/components/insight/charts/RetentionChartCard.tsx @@ -0,0 +1,97 @@ +// src/pages/insight/charts/RetentionChartCard.tsx +import { + Area, + AreaChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +import type { ChartDataPoint } from '../types'; +import { RetentionChartTooltip } from './RetentionChartTooltip'; + +interface Props { + title: string; + data: ChartDataPoint[]; + isVideo: boolean; +} + +export function RetentionChartCard({ title, data, isVideo }: Props) { + return ( +
+

{title}

+ +
+ {data.length > 0 ? ( + + + + + + + + + + + + + + + + } + cursor={{ stroke: 'var(--color-error)', strokeDasharray: '4 4', strokeWidth: 1 }} + /> + + + + + ) : ( +
+

데이터를 분석 중이거나 결과가 없습니다.

+
+ )} +
+
+ ); +} diff --git a/src/components/insight/charts/RetentionChartTooltip.tsx b/src/components/insight/charts/RetentionChartTooltip.tsx new file mode 100644 index 00000000..ca5b25c7 --- /dev/null +++ b/src/components/insight/charts/RetentionChartTooltip.tsx @@ -0,0 +1,28 @@ +// src/pages/insight/charts/RetentionChartTooltip.tsx +import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; +import type { TooltipContentProps } from 'recharts/types/component/Tooltip'; + +import type { ChartDataPoint } from '../types'; + +export function RetentionChartTooltip({ + active, + payload, + label, + hasVideo, +}: TooltipContentProps & { hasVideo: boolean }) { + if (active && payload && payload.length) { + const data = payload[0].payload as ChartDataPoint; + return ( +
+

+ {hasVideo ? `재생 시간: ${label}` : `${data.tooltipTitle}`} +

+
+

잔존율 {data.value}%

+ ({data.sessionCount}명) +
+
+ ); + } + return null; +} diff --git a/src/components/insight/index.ts b/src/components/insight/index.ts index 5e0b356e..3eb5769c 100644 --- a/src/components/insight/index.ts +++ b/src/components/insight/index.ts @@ -4,3 +4,5 @@ export { default as RecentCommentItem } from './RecentCommentItem'; export { default as SummaryStatsSection } from './SummaryStatsSection'; export { default as DropOffAnalysisSection } from './DropOffAnalysisSection'; export { default as FeedbackDistributionSection } from './FeedbackDistributionSection'; +export { RecentCommentsSection } from './RecentCommentsSection'; +export * from './types'; diff --git a/src/components/insight/types.ts b/src/components/insight/types.ts new file mode 100644 index 00000000..70cfd757 --- /dev/null +++ b/src/components/insight/types.ts @@ -0,0 +1,46 @@ +// src/components/insight/types.ts +import type { ReadRecentCommentListResponseDto } from '@/api/dto/analytics.dto'; +import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; +import type { ReactionType } from '@/types/script'; +import type { SlideListItem } from '@/types/slide'; + +// 기존 공용 타입 재사용 + +export interface ChartDataPoint { + label: string; + value: number; + tooltipTitle: string; + sessionCount: number; + originalTime?: number; +} + +export type InsightTopSlide = { + slideId: string; + slide?: SlideListItem; + slideIndex: number; + title: string; + commentCount: number; + feedbackCount: number; +}; + +export type InsightModel = { + projectIdStr: string; + projectIdNum: number; + + hasVideo: boolean; + + summaryStats: SummaryStat[]; + + dropOffSlides: DropOffSlide[]; + dropOffTimes: DropOffTime[]; + + retentionTitle: string; + retentionData: ChartDataPoint[]; + retentionIsVideo: boolean; + + topSlides: InsightTopSlide[]; + topSlideReactionSummaries?: Array>; + getThumb: (slideIndex: number) => string | undefined; + + recentCommentsData: ReadRecentCommentListResponseDto | undefined; +}; diff --git a/src/hooks/queries/useSlides.ts b/src/hooks/queries/useSlides.ts index 24f43097..d9e35ee8 100644 --- a/src/hooks/queries/useSlides.ts +++ b/src/hooks/queries/useSlides.ts @@ -1,3 +1,7 @@ +/** + * 슬라이드 관련 TanStack Query 훅 + */ + import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { UpdateSlideTitleRequestDto } from '@/api/dto'; diff --git a/src/hooks/useInsightPageModel.ts b/src/hooks/useInsightPageModel.ts new file mode 100644 index 00000000..b7db19ba --- /dev/null +++ b/src/hooks/useInsightPageModel.ts @@ -0,0 +1,234 @@ +// src/hooks/useInsightPageModel.ts +import { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; + +import type { + SlideAnalyticsDto, + SlideRetentionDto, + VideoExitAnalyticsDto, + VideoRetentionDto, +} from '@/api/dto/analytics.dto'; +import type { ChartDataPoint, InsightModel, InsightTopSlide } from '@/components/insight/types'; +import { + useProjectAnalyticsSummary, + useRecentComments, + useSlideAnalytics, + useSlideRetention, + useVideoAnalytics, + useVideoRetention, +} from '@/hooks/queries/useAnalytics'; +import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; +import { useSlides } from '@/hooks/queries/useSlides'; +import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; +import type { SlideListItem } from '@/types/slide'; +import { formatVideoTimestamp } from '@/utils/format'; +import { getSlideIndexFromTime } from '@/utils/video'; + +const FALLBACK_SLIDE_DURATION_SECONDS = 10; +const summaryStatLabels = ['총 조회수', '완료율', '받은 피드백', '평균 체류 시간'] as const; + +const emptySummaryStats: SummaryStat[] = summaryStatLabels.map((label) => ({ + label, + value: '-', + sub: '', +})); + +const normalizeRate = (rate: number) => (rate <= 1 ? rate * 100 : rate); + +export function useInsightPageModel(): InsightModel { + const { projectId } = useParams<{ projectId: string }>(); + const projectIdStr = projectId ?? ''; + const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; + + const { data: slides } = useSlides(projectIdStr); + const { data: slideAnalytics } = useSlideAnalytics(projectIdNum); + const { data: summaryAnalytics } = useProjectAnalyticsSummary(projectIdNum); + const { data: recentCommentsData } = useRecentComments(projectIdNum); + + const videoIdStr = summaryAnalytics?.videoIds?.[0] ?? ''; + const videoIdNum = videoIdStr ? Number(videoIdStr) : 0; + const hasVideo = !!videoIdNum; + + const { data: videoExitAnalytics } = useVideoAnalytics(videoIdNum); + + // ---- Summary stats ---- + const computedSummaryStats = useMemo(() => { + if (!summaryAnalytics) return emptySummaryStats; + + const completionRate = + summaryAnalytics.completionRate <= 1 + ? summaryAnalytics.completionRate * 100 + : summaryAnalytics.completionRate; + + return [ + { label: summaryStatLabels[0], value: String(summaryAnalytics.totalViews), sub: '' }, + { label: summaryStatLabels[1], value: `${Math.round(completionRate)}%`, sub: '' }, + { label: summaryStatLabels[2], value: String(summaryAnalytics.totalFeedbackCount), sub: '' }, + { + label: summaryStatLabels[3], + value: formatVideoTimestamp(summaryAnalytics.avgDurationSeconds), + sub: '', + }, + ]; + }, [summaryAnalytics]); + + const summaryStats = hasVideo + ? computedSummaryStats + : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]); + + // ---- Slides map / thumbs ---- + const slideList = useMemo(() => (Array.isArray(slides) ? slides : []), [slides]); + + const slideDataMaps = useMemo(() => { + const slideIndexById = new Map(); + const slideById = new Map(); + + slideList.forEach((slide, index) => { + slideIndexById.set(slide.slideId, index); + slideById.set(slide.slideId, slide); + }); + + return { slideIndexById, slideById }; + }, [slideList]); + + const convertGcsToHttpUrl = (gcsUrl?: string) => + gcsUrl?.startsWith('gs://') ? `https://storage.googleapis.com/${gcsUrl.slice(5)}` : gcsUrl; + + const getThumb = (slideIndex: number) => convertGcsToHttpUrl(slideList[slideIndex]?.imageUrl); + + // ---- Top slides ---- + const topSlides = useMemo(() => { + const analyticsSlides: SlideAnalyticsDto[] = slideAnalytics?.slides ?? []; + if (!analyticsSlides.length) return []; + + const { slideIndexById, slideById } = slideDataMaps; + + return analyticsSlides + .slice() + .sort((a, b) => b.feedbackCount - a.feedbackCount) + .slice(0, 3) + .map((item) => { + const slide = slideById.get(item.slideId); + const slideIndex = slideIndexById.get(item.slideId) ?? Math.max(0, item.slideNum - 1); + const title = slide?.title || item.title || `슬라이드 ${slideIndex + 1}`; + + return { + slideId: item.slideId, + slide, + slideIndex, + title, + commentCount: item.commentCount, + feedbackCount: item.feedbackCount, + }; + }); + }, [slideAnalytics, slideDataMaps]); + + const topSlideIds = useMemo(() => topSlides.map((item) => item.slideId), [topSlides]); + const { data: topSlideReactionSummaries } = useSlideReactionSummaries(topSlideIds); + + // ---- Drop-off ---- + const slideChangeTimes = useMemo(() => { + if (!slides?.length) return []; + return slides.map((slide, index) => + Number.isFinite(slide.startTime) + ? (slide.startTime ?? 0) + : index * FALLBACK_SLIDE_DURATION_SECONDS, + ); + }, [slides]); + + const dropOffSlides: DropOffSlide[] = useMemo(() => { + const items: SlideAnalyticsDto[] = slideAnalytics?.slides ?? []; + return items + .slice() + .sort((a, b) => b.exitCount - a.exitCount) + .slice(0, 3) + .map((item) => ({ + label: `슬라이드 ${item.slideNum}`, + desc: `${item.exitCount}명 이탈`, + percent: Math.min( + 100, + Math.round( + item.exitRate <= 1 + ? item.exitRate * 100 + : item.exitRate > 100 + ? item.exitRate / 100 + : item.exitRate, + ), + ), + slideIndex: Math.max(0, item.slideNum - 1), + count: item.exitCount, + })); + }, [slideAnalytics]); + + const dropOffTimes: DropOffTime[] = useMemo(() => { + const items: VideoExitAnalyticsDto[] = videoExitAnalytics?.exits ?? []; + return items + .slice() + .sort((a, b) => b.exitCount - a.exitCount) + .slice(0, 3) + .map((item) => { + const seconds = item.timestampMs / 1000; + const slideIndex = + slides?.length && slideChangeTimes.length + ? getSlideIndexFromTime(seconds, slideChangeTimes, slides.length - 1) + : 0; + + return { + time: formatVideoTimestamp(seconds), + desc: slides?.length ? `슬라이드 ${slideIndex + 1}` : '슬라이드', + count: item.exitCount, + slideIndex, + }; + }); + }, [videoExitAnalytics, slideChangeTimes, slides]); + + // ---- Retention(잔존율) ---- + const { data: videoRetentionRes } = useVideoRetention(videoIdNum); + const { data: slideRetentionRes } = useSlideRetention(projectIdNum); + + const videoChartData = useMemo(() => { + if (!videoRetentionRes?.videoRetention) return []; + return videoRetentionRes.videoRetention.map((item: VideoRetentionDto) => ({ + label: formatVideoTimestamp(item.timestampMs / 1000), // x축: 00:00 + value: Math.round(normalizeRate(item.retentionRate)), // y축: 0~100% + tooltipTitle: formatVideoTimestamp(item.timestampMs / 1000), + sessionCount: item.sessionCount, + originalTime: item.timestampMs, + })); + }, [videoRetentionRes]); + + const slideChartData = useMemo(() => { + if (!slideRetentionRes?.slideRetention) return []; + return slideRetentionRes.slideRetention.map((item: SlideRetentionDto) => ({ + label: `S${item.slideNum}`, // x축: S1, S2 + value: Math.round(normalizeRate(item.retentionRate)), + tooltipTitle: item.title || `슬라이드 ${item.slideNum}`, // 툴팁: 제목 + sessionCount: item.sessionCount, + })); + }, [slideRetentionRes]); + + const retentionTitle = hasVideo ? '영상 시청 잔존률' : '슬라이드별 청중 잔존률'; + const retentionData = hasVideo ? videoChartData : slideChartData; + + return { + projectIdStr, + projectIdNum, + hasVideo, + + summaryStats, + + dropOffSlides, + dropOffTimes, + + retentionTitle, + retentionData, + retentionIsVideo: hasVideo, + + topSlides, + topSlideReactionSummaries, + + getThumb, + + recentCommentsData, + }; +} diff --git a/src/pages/InsightPage.tsx b/src/pages/InsightPage.tsx index fdad69b1..f84de03c 100644 --- a/src/pages/InsightPage.tsx +++ b/src/pages/InsightPage.tsx @@ -1,339 +1,15 @@ -import { useMemo } from 'react'; -import { useParams } from 'react-router-dom'; - -// 1. Recharts 컴포넌트 임포트 -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts'; -import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; -import type { TooltipContentProps } from 'recharts/types/component/Tooltip'; - -import { - DropOffAnalysisSection, - FeedbackDistributionSection, - RecentCommentItem, - SummaryStatsSection, - TopSlideCard, -} from '@/components/insight'; +// src/pages/insight/InsightPage.tsx +import { SummaryStatsSection } from '@/components/insight'; +import { DropOffAnalysisSection } from '@/components/insight'; +import { FeedbackDistributionSection } from '@/components/insight'; +import { TopSlideCard } from '@/components/insight'; +import { RecentCommentsSection } from '@/components/insight/RecentCommentsSection'; +import { RetentionChartCard } from '@/components/insight/charts/RetentionChartCard'; import { createDefaultReactions } from '@/constants/reaction'; -import { - useProjectAnalyticsSummary, - useRecentComments, - useSlideAnalytics, - useSlideRetention, - useVideoAnalytics, - useVideoRetention, -} from '@/hooks/queries/useAnalytics'; -import { useSlideReactionSummaries } from '@/hooks/queries/useReaction.ts'; -import { useSlides } from '@/hooks/queries/useSlides'; -import type { DropOffSlide, DropOffTime, SummaryStat } from '@/types/insight'; -import type { SlideListItem } from '@/types/slide'; -import { formatVideoTimestamp } from '@/utils/format'; -import { getSlideIndexFromTime } from '@/utils/video'; - -// --- 타입 및 스타일 정의 --- -// 툴팁용 타입 -interface ChartDataPoint { - label: string; - value: number; - tooltipTitle: string; - sessionCount: number; - originalTime?: number; // 영상 시간 계산용 -} - -// 디자인을 위해 스타일을 조금 더 부드럽게 조정함 -const thumbBase = 'bg-gray-100 rounded-lg aspect-video'; -const FALLBACK_SLIDE_DURATION_SECONDS = 10; - -// --- 더미 데이터 (사진 수치 반영) --- -const summaryStatLabels = ['총 조회수', '완료율', '받은 피드백', '평균 체류 시간'] as const; -const emptySummaryStats: SummaryStat[] = summaryStatLabels.map((label) => ({ - label, - value: '-', - sub: '', -})); - -// --- 커스텀 툴팁 컴포넌트 --- -const CustomTooltip = ({ - active, - payload, - label, - hasVideo, -}: TooltipContentProps & { hasVideo: boolean }) => { - if (active && payload && payload.length) { - const data = payload[0].payload as ChartDataPoint; - return ( -
-

- {hasVideo ? `재생 시간: ${label}` : `슬라이드: ${data.tooltipTitle}`} -

-
-

잔존율 {data.value}%

- ({data.sessionCount}명) -
-
- ); - } - return null; -}; - -// --- 컴포넌트 시작 --- - -const normalizeRate = (rate: number) => (rate <= 1 ? rate * 100 : rate); +import { useInsightPageModel } from '@/hooks/useInsightPageModel'; export default function InsightPage() { - const { projectId: projectIdStr } = useParams<{ projectId: string }>(); - const projectIdNum = projectIdStr ? Number(projectIdStr) : 0; - const { data: slides } = useSlides(projectIdStr ?? ''); - const { data: slideAnalytics } = useSlideAnalytics(projectIdNum); - const { data: summaryAnalytics } = useProjectAnalyticsSummary(projectIdNum); - const { data: recentCommentsData } = useRecentComments(projectIdNum); - const videoIdStr = summaryAnalytics?.videoIds?.[0] ?? ''; - const videoIdNum = videoIdStr ? Number(videoIdStr) : 0; - const hasVideo = !!videoIdNum; - const { data: videoExitAnalytics } = useVideoAnalytics(videoIdNum); - const computedSummaryStats = useMemo(() => { - if (!summaryAnalytics) return emptySummaryStats; - - const completionRate = - summaryAnalytics.completionRate <= 1 - ? summaryAnalytics.completionRate * 100 - : summaryAnalytics.completionRate; - - return [ - { label: summaryStatLabels[0], value: String(summaryAnalytics.totalViews), sub: '' }, - { label: summaryStatLabels[1], value: `${Math.round(completionRate)}%`, sub: '' }, - { - label: summaryStatLabels[2], - value: String(summaryAnalytics.totalFeedbackCount), - sub: '', - }, - { - label: summaryStatLabels[3], - value: formatVideoTimestamp(summaryAnalytics.avgDurationSeconds), - sub: '', - }, - ]; - }, [summaryAnalytics]); - - const visibleSummaryStats = hasVideo - ? computedSummaryStats - : computedSummaryStats.filter((stat) => stat.label !== summaryStatLabels[3]); - - const slideList = useMemo(() => (Array.isArray(slides) ? slides : []), [slides]); - - const slideDataMaps = useMemo(() => { - const slideIndexById = new Map(); - const slideById = new Map(); - - slideList.forEach((slide, index) => { - slideIndexById.set(slide.slideId, index); - slideById.set(slide.slideId, slide); - }); - - return { slideIndexById, slideById }; - }, [slideList]); - - const topSlides = useMemo(() => { - const analyticsSlides = slideAnalytics?.slides ?? []; - if (!analyticsSlides.length) return []; - const { slideIndexById, slideById } = slideDataMaps; - - return analyticsSlides - .slice() - .sort((a, b) => b.feedbackCount - a.feedbackCount) - .slice(0, 3) - .map((item) => { - const slide = slideById.get(item.slideId); - const slideIndex = slideIndexById.get(item.slideId) ?? Math.max(0, item.slideNum - 1); - const title = slide?.title || item.title || `슬라이드 ${slideIndex + 1}`; - - return { - slideId: item.slideId, - slide, - slideIndex, - title, - commentCount: item.commentCount, - feedbackCount: item.feedbackCount, - }; - }); - }, [slideAnalytics, slideDataMaps]); - - const topSlideIds = useMemo(() => topSlides.map((item) => item.slideId), [topSlides]); - const { data: topSlideReactionSummaries } = useSlideReactionSummaries(topSlideIds); - - const toPublicUrl = (url?: string) => - url?.startsWith('gs://') ? `https://storage.googleapis.com/${url.slice(5)}` : url; - const getThumb = (slideIndex: number) => toPublicUrl(slideList[slideIndex]?.imageUrl); - - const slideChangeTimes = useMemo(() => { - if (!slides?.length) return []; - - return slides.map((slide, index) => - Number.isFinite(slide.startTime) - ? (slide.startTime ?? 0) - : index * FALLBACK_SLIDE_DURATION_SECONDS, - ); - }, [slides]); - - const dropOffSlides: DropOffSlide[] = useMemo(() => { - const items = slideAnalytics?.slides ?? []; - return items - .slice() - .sort((a, b) => b.exitCount - a.exitCount) - .slice(0, 3) - .map((item) => ({ - label: `슬라이드 ${item.slideNum}`, - desc: `${item.exitCount}명 이탈`, - percent: Math.min( - 100, - Math.round( - item.exitRate <= 1 - ? item.exitRate * 100 - : item.exitRate > 100 - ? item.exitRate / 100 - : item.exitRate, - ), - ), - slideIndex: Math.max(0, item.slideNum - 1), - })); - }, [slideAnalytics]); - - const dropOffTimes: DropOffTime[] = useMemo(() => { - const items = videoExitAnalytics?.exits ?? []; - return items - .slice() - .sort((a, b) => b.exitCount - a.exitCount) - .slice(0, 3) - .map((item) => { - const seconds = item.timestampMs / 1000; - const slideIndex = - slides?.length && slideChangeTimes.length - ? getSlideIndexFromTime(seconds, slideChangeTimes, slides.length - 1) - : 0; - return { - time: formatVideoTimestamp(seconds), - desc: slides?.length ? `슬라이드 ${slideIndex + 1}` : '슬라이드', - count: item.exitCount, - slideIndex, - }; - }); - }, [videoExitAnalytics, slideChangeTimes, slides]); - - // 잔존율 데이터 Fetching - // 1. 영상 잔존율 (영상이 있을 때만 호출) - const { data: videoRetentionRes } = useVideoRetention(videoIdNum); - - // 2. 슬라이드 잔존율 (영상이 없을 때 호출) - const { data: slideRetentionRes } = useSlideRetention(projectIdNum); - - // --- Chart Data 가공 --- - const videoChartData = useMemo(() => { - if (!videoRetentionRes?.videoRetention) return []; - return videoRetentionRes.videoRetention.map((item) => ({ - label: formatVideoTimestamp(item.timestampMs / 1000), // x축: 00:00 - value: Math.round(normalizeRate(item.retentionRate)), // y축: 0~100% - tooltipTitle: formatVideoTimestamp(item.timestampMs / 1000), - sessionCount: item.sessionCount, - originalTime: item.timestampMs, - })); - }, [videoRetentionRes]); - - const slideChartData = useMemo(() => { - if (!slideRetentionRes?.slideRetention) return []; - return slideRetentionRes.slideRetention.map((item) => ({ - label: `S${item.slideNum}`, // x축: S1, S2 - value: Math.round(normalizeRate(item.retentionRate)), - tooltipTitle: item.title || `슬라이드 ${item.slideNum}`, // 툴팁: 제목 - sessionCount: item.sessionCount, - })); - }, [slideRetentionRes]); - - const renderRetentionChart = (title: string, data: ChartDataPoint[], isVideo: boolean) => ( -
-

{title}

-
- {data.length > 0 ? ( - - - - - - - - - - - - - - - - } - cursor={{ stroke: 'var(--color-error)', strokeDasharray: '4 4', strokeWidth: 1 }} - /> - - - - - ) : ( -
-

데이터를 분석 중이거나 결과가 없습니다.

-
- )} -
-
- ); + const m = useInsightPageModel(); return (
- {/* 헤더 */}

발표 인사이트

발표 자료 분석 결과를 확인하세요

- {/* 통계 카드 */}
- + - {/* 이탈 분석 */} - {/* 잔존률 차트 */} - {renderRetentionChart( - hasVideo ? '영상 시청 잔존률' : '슬라이드별 청중 잔존률', - hasVideo ? videoChartData : slideChartData, - hasVideo, - )} +
- +

가장 많은 피드백을 받은 슬라이드

-
- {topSlides.map(({ slideId, slide, slideIndex, title }, index) => { - const summary = topSlideReactionSummaries?.[index]; +
+ {m.topSlides.map(({ slideId, slide, slideIndex, title }, index) => { + const summary = m.topSlideReactionSummaries?.[index]; const baseReactions = createDefaultReactions(); const summaryReactions = summary ? baseReactions.map((reaction) => ({ @@ -389,7 +61,7 @@ export default function InsightPage() { ); @@ -397,51 +69,7 @@ export default function InsightPage() {
- -
-
-
-

최근 댓글 피드백

- {recentCommentsData?.comments && recentCommentsData.comments.length > 0 ? ( - recentCommentsData.comments.map((comment) => ( - - )) - ) : ( - /* 댓글이 없을 경우 표시할 UI (선택 사항) */ -
- 아직 등록된 댓글이 없습니다. -
- )} -
- - {/* 오버레이: 이 영역 안에서만 덮음 */} - {!hasVideo && ( -
-
-

- 영상을 녹화하면 더 자세한 분석을 받을 수 있어요 -

-
    -
  • • 시청 구간별 이탈률 분석
  • -
  • • 영상 잔존율 그래프
  • -
  • • 타임라인 기반 댓글 피드백
  • -
-
-
- )} -
-
+
diff --git a/src/types/insight.ts b/src/types/insight.ts index 522ad324..7eea2a2e 100644 --- a/src/types/insight.ts +++ b/src/types/insight.ts @@ -11,6 +11,7 @@ export type DropOffSlide = { desc: string; percent: number; slideIndex: number; + count: number; }; export type DropOffTime = {