Skip to content
Merged
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
61 changes: 37 additions & 24 deletions src/components/insight/DropOffAnalysisSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DropOffSlide, DropOffTime } from '@/types/insight';
import type { DropOffSlide, DropOffTime } from '@/types/insight';

import SlideThumb from './SlideThumb';

Expand All @@ -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 (
<div className="flex flex-wrap gap-4">
{/* 슬라이드 이탈 */}
<div className="flex min-w-80 flex-1 basis-160 flex-col gap-1 rounded-lg border border-gray-200 bg-white px-5 py-4">
<h3 className="text-body-l-bold text-gray-800">가장 많이 이탈한 슬라이드</h3>

{isSlideOnly ? (
{!hasSlideDropOff ? (
<div className="mt-4 flex h-24 items-center justify-center text-gray-400">
<p>{noDataMessage}</p>
</div>
) : isSlideOnly ? (
// !hasVideo UI
<div className="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-3">
{dropOffSlides.map((item, idx) => (
Expand Down Expand Up @@ -74,29 +81,35 @@ export default function DropOffAnalysisSection({
{showVideoDropOff && (
<div className="flex min-w-80 flex-1 basis-160 flex-col gap-1 rounded-lg border border-gray-200 bg-white px-5 py-4">
<h3 className="text-body-l-bold text-gray-800">가장 많이 이탈한 영상 구간</h3>
{dropOffTimes.map((item, idx) => (
<div
key={idx}
className={`flex h-24.75 items-center gap-6 py-4 pr-2 ${
idx < dropOffTimes.length - 1 ? 'border-b border-gray-200' : ''
}`}
>
<SlideThumb
src={getThumb(item.slideIndex)}
alt={`슬라이드 ${item.slideIndex + 1} 썸네일`}
className="h-16.75 w-30 shrink-0 rounded object-cover"
fallbackClassName="h-[67px] w-[120px] shrink-0 rounded bg-gray-200"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body-m-bold text-gray-800">{item.time}</span>
<span className="text-caption text-gray-600">{item.desc}</span>
</div>
<div className="flex shrink-0 flex-col items-end">
<span className="text-body-l-bold text-error">{item.count}명</span>
<span className="text-caption text-gray-600">이탈</span>
</div>
{!hasVideoDropOff ? (
<div className="mt-4 flex h-24 items-center justify-center text-gray-400">
<p>{noDataMessage}</p>
</div>
))}
) : (
dropOffTimes.map((item, idx) => (
<div
key={idx}
className={`flex h-24.75 items-center gap-6 py-4 pr-2 ${
idx < dropOffTimes.length - 1 ? 'border-b border-gray-200' : ''
}`}
>
<SlideThumb
src={getThumb(item.slideIndex)}
alt={`슬라이드 ${item.slideIndex + 1} 썸네일`}
className="h-16.75 w-30 shrink-0 rounded object-cover"
fallbackClassName="h-[67px] w-[120px] shrink-0 rounded bg-gray-200"
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-body-m-bold text-gray-800">{item.time}</span>
<span className="text-caption text-gray-600">{item.desc}</span>
</div>
<div className="flex shrink-0 flex-col items-end">
<span className="text-body-l-bold text-error">{item.count}명</span>
<span className="text-caption text-gray-600">이탈</span>
</div>
</div>
))
)}
</div>
)}
</div>
Expand Down
59 changes: 59 additions & 0 deletions src/components/insight/RecentCommentsSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full flex-col gap-4">
<div className="relative">
<div
className={`flex flex-col gap-2 ${!hasVideo ? 'blur-sm pointer-events-none select-none' : ''}`}
>
<h3 className="text-body-l-bold text-gray-800">최근 댓글 피드백</h3>

{recentCommentsData?.comments && recentCommentsData.comments.length > 0 ? (
recentCommentsData.comments.map((comment) => (
<RecentCommentItem
key={comment.commentId}
user={comment.user.name}
slideLabel={`슬라이드 ${comment.slide.slideNum}`}
time={formatVideoTimestamp(comment.timestampMs / 1000)}
text={comment.content}
thumbUrl={comment.slide.imageUrl}
thumbFallbackClassName={thumbBase}
/>
))
) : (
<div className="py-4 text-center text-gray-400 text-body-s">
아직 등록된 댓글이 없습니다.
</div>
)}
</div>

{!hasVideo && (
<div className="absolute inset-0 z-10 flex items-center justify-center text-center pointer-events-auto">
<div className="px-6 py-5">
<p className="text-body-l-bold text-gray-800">
영상을 녹화하면 더 자세한 분석을 받을 수 있어요
</p>
<ul className="mt-3 mx-auto w-fit text-left text-body-m text-gray-800">
<li>• 시청 구간별 이탈률 분석</li>
<li>• 영상 잔존율 그래프</li>
<li>• 타임라인 기반 댓글 피드백</li>
</ul>
</div>
</div>
)}
</div>
</div>
);
}
97 changes: 97 additions & 0 deletions src/components/insight/charts/RetentionChartCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex w-full flex-col gap-6 rounded-lg border border-gray-200 bg-white px-5 pb-8 pt-4">
<h3 className="text-body-l-bold text-gray-800">{title}</h3>

<div className="h-100 w-full px-6">
{data.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient
id={`colorRate-${isVideo ? 'video' : 'slide'}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor="var(--color-main)" stopOpacity={0.2} />
<stop offset="95%" stopColor="var(--color-main)" stopOpacity={0} />
</linearGradient>
</defs>

<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-gray-400)"
/>

<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: 'var(--color-gray-600)', fontWeight: 600 }}
dy={10}
interval={isVideo ? 'preserveStartEnd' : 0}
minTickGap={30}
/>

<YAxis
domain={[0, 100]}
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: 'var(--color-gray-600)' }}
ticks={[0, 25, 50, 75, 100]}
unit="%"
/>

<Tooltip
content={(props) => <RetentionChartTooltip {...props} hasVideo={isVideo} />}
cursor={{ stroke: 'var(--color-error)', strokeDasharray: '4 4', strokeWidth: 1 }}
/>

<Area
type="monotone"
dataKey="value"
stroke="var(--color-main)"
strokeWidth={2}
fillOpacity={1}
fill={`url(#colorRate-${isVideo ? 'video' : 'slide'})`}
dot={
!isVideo
? { r: 4, fill: '#fff', stroke: 'var(--color-main)', strokeWidth: 2 }
: false
}
activeDot={{ r: 5, fill: 'var(--color-error)', stroke: '#fff', strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-gray-400">
<p>데이터를 분석 중이거나 결과가 없습니다.</p>
</div>
)}
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions src/components/insight/charts/RetentionChartTooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<ValueType, NameType> & { hasVideo: boolean }) {
if (active && payload && payload.length) {
const data = payload[0].payload as ChartDataPoint;
return (
<div className="rounded-lg border border-gray-100 bg-white p-3 shadow-lg">
<p className="mb-1 text-xs font-semibold text-gray-500">
{hasVideo ? `재생 시간: ${label}` : `${data.tooltipTitle}`}
</p>
<div className="flex items-end gap-2">
<p className="text-sm font-bold text-indigo-600">잔존율 {data.value}%</p>
<span className="text-xs text-gray-400">({data.sessionCount}명)</span>
</div>
</div>
);
}
return null;
}
2 changes: 2 additions & 0 deletions src/components/insight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
46 changes: 46 additions & 0 deletions src/components/insight/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<ReactionType, number>>;
getThumb: (slideIndex: number) => string | undefined;

recentCommentsData: ReadRecentCommentListResponseDto | undefined;
};
4 changes: 4 additions & 0 deletions src/hooks/queries/useSlides.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* 슬라이드 관련 TanStack Query 훅
*/

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';

import type { UpdateSlideTitleRequestDto } from '@/api/dto';
Expand Down
Loading
Loading