Skip to content

Commit 6247ae3

Browse files
authored
Merge pull request #138 from JECT-Study/feat/IN-271
feat(IN-271): 경쟁사 영상 분석 - 선택 액션바 및 AI 인사이트 구현
2 parents ee300c7 + 44267e4 commit 6247ae3

15 files changed

Lines changed: 588 additions & 9 deletions

File tree

src/features/competitor/api/competitorApi.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { axiosInstance } from '@/shared/api'
33
import type {
44
BrandCollaborationsQuery,
55
BrandCollaborationsResponseDto,
6+
BrandCollaborationsTrendsRequest,
7+
BrandCollaborationsTrendsResponseDto,
68
} from '../model/types'
79

810
export async function fetchBrandCollaborations(
@@ -16,3 +18,13 @@ export async function fetchBrandCollaborations(
1618
})
1719
return res.data.responseDto
1820
}
21+
22+
/* AI 분석 인사이트 — 선택한 영상들로 콘텐츠/채널/전략 분석 결과 조회 */
23+
export async function fetchBrandCollaborationsTrends(
24+
body: BrandCollaborationsTrendsRequest
25+
): Promise<BrandCollaborationsTrendsResponseDto> {
26+
const res = await axiosInstance.post<
27+
ApiResponse<BrandCollaborationsTrendsResponseDto>
28+
>('/brand-collaborations/trends', body)
29+
return res.data.responseDto
30+
}

src/features/competitor/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ export type {
77
BrandCollaborationsSort,
88
BrandCollaborationsResponseDto,
99
BrandCollaborationsQuery,
10+
BrandCollaborationsTrendsRequest,
11+
BrandCollaborationsTrendsResponseDto,
12+
TrendsContentKeywords,
13+
TrendsChannelCharacteristics,
14+
TrendsCategoryDistribution,
15+
TrendsStrategyInsight,
1016
} from './model/types'
1117
export { DEFAULT_COMPETITOR_FILTER } from './model/types'
1218

@@ -23,4 +29,9 @@ export {
2329
toBrandCollaborationsQuery,
2430
} from './model/useBrandCollaborations'
2531

26-
export { fetchBrandCollaborations } from './api/competitorApi'
32+
export { useBrandCollaborationsTrends } from './model/useBrandCollaborationsTrends'
33+
34+
export {
35+
fetchBrandCollaborations,
36+
fetchBrandCollaborationsTrends,
37+
} from './api/competitorApi'

src/features/competitor/model/types.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,53 @@ export interface BrandCollaborationsResponseDto {
8888
pageInfo: PageInfo
8989
sort: BrandCollaborationsSort
9090
}
91+
92+
/* ─────────── AI 분석 인사이트 ─────────── */
93+
94+
/* trends 요청 body */
95+
export interface BrandCollaborationsTrendsRequest {
96+
/* 분석할 YouTube 영상 ID 목록 (1~10개) */
97+
youtubeVideoIds: string[]
98+
}
99+
100+
/* AI 분석 키워드 정보 */
101+
export interface TrendsContentKeywords {
102+
/* AI 추출 공통 키워드 목록 — AI 실패 시 [] */
103+
keywords: string[]
104+
/* 키워드 요약 (AI 생성) — AI 실패 시 null */
105+
keywordSummary: string | null
106+
}
107+
108+
/* 카테고리별 비율 */
109+
export interface TrendsCategoryDistribution {
110+
category: string
111+
/* 0~100 (소수점 1자리) */
112+
percentage: number
113+
}
114+
115+
/* 협업 채널 통계 */
116+
export interface TrendsChannelCharacteristics {
117+
channelCount: number
118+
avgSubscribers: number
119+
minSubscribers: number
120+
maxSubscribers: number
121+
/* 평균 업로드 주기 (일) — Nullable */
122+
uploadIntervalDays: number | null
123+
/* 결과 없으면 [] */
124+
categoryDistribution: TrendsCategoryDistribution[]
125+
}
126+
127+
/* AI 전략 인사이트 */
128+
export interface TrendsStrategyInsight {
129+
/* PPL 의도 분석 요약 — AI 실패 시 null */
130+
pplIntent: string | null
131+
/* 경쟁사 협업 전략의 강점 — AI 실패 시 null */
132+
competitivePoints: string | null
133+
}
134+
135+
/* trends 응답 DTO */
136+
export interface BrandCollaborationsTrendsResponseDto {
137+
contentKeywords: TrendsContentKeywords
138+
channelCharacteristics: TrendsChannelCharacteristics
139+
strategyInsight: TrendsStrategyInsight
140+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client'
2+
3+
import { useQuery } from '@tanstack/react-query'
4+
5+
import { fetchBrandCollaborationsTrends } from '../api/competitorApi'
6+
7+
interface UseBrandCollaborationsTrendsOptions {
8+
/* 분석 대상 영상 ID 배열 (1~10) */
9+
videoIds: string[]
10+
enabled?: boolean
11+
}
12+
13+
/**
14+
* 선택한 영상들로 AI 분석 인사이트(trends) 조회
15+
* - queryKey에 정렬된 videoIds를 포함해 동일 영상셋이면 캐시 재사용
16+
* - 빈 배열이거나 10개 초과면 자동 비활성화
17+
*/
18+
export function useBrandCollaborationsTrends({
19+
videoIds,
20+
enabled = true,
21+
}: UseBrandCollaborationsTrendsOptions) {
22+
const sortedIds = [...videoIds].sort()
23+
const isValidCount = sortedIds.length > 0 && sortedIds.length <= 10
24+
25+
return useQuery({
26+
queryKey: ['brand-collaborations-trends', sortedIds],
27+
queryFn: () =>
28+
fetchBrandCollaborationsTrends({ youtubeVideoIds: sortedIds }),
29+
enabled: enabled && isValidCount,
30+
/* 같은 영상셋이면 새 조건이 되기 전까지 stale 처리 안 함 */
31+
staleTime: Infinity,
32+
})
33+
}

src/pages/competitor/ui/CompetitorPage.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Lightbulb } from 'lucide-react'
77

88
import {
99
CompetitorFilterPanel,
10+
CompetitorInsightSection,
1011
CompetitorResultSection,
1112
CompetitorSelectionBar,
1213
} from '@/widgets/competitor'
@@ -37,6 +38,12 @@ export function CompetitorPage() {
3738
new Set()
3839
)
3940

41+
/* 분석 트리거된 영상 ID 배열 — '영상 분석하기' 클릭 시 선택 영상의 스냅샷 */
42+
const [analyzedVideoIds, setAnalyzedVideoIds] = useState<string[]>([])
43+
44+
/* 상세 검색 영역 열림 상태 — 분석 완료 시 자동 닫기 위해 페이지에서 관리 */
45+
const [isDetailOpen, setIsDetailOpen] = useState(false)
46+
4047
const { data, hasNextPage, isFetchingNextPage, fetchNextPage } =
4148
useBrandCollaborations({ filter: appliedFilter })
4249

@@ -50,17 +57,19 @@ export function CompetitorPage() {
5057
setDraftFilter((prev) => ({ ...prev, [key]: value }))
5158
}
5259

53-
/* 초기화: 편집 필터 + 적용 필터 + 선택 영상 모두 초기 상태로 */
60+
/* 초기화: 편집 필터 + 적용 필터 + 선택/분석 영상 모두 초기 상태로 */
5461
function handleReset() {
5562
setDraftFilter(DEFAULT_COMPETITOR_FILTER)
5663
setAppliedFilter(null)
5764
setSelectedVideoIds(new Set())
65+
setAnalyzedVideoIds([])
5866
}
5967

6068
/* 검색: 편집 필터를 확정. 동일 조건 재검색 시에도 강제 refetch */
6169
function handleSearch() {
6270
setAppliedFilter(draftFilter)
6371
setSelectedVideoIds(new Set())
72+
setAnalyzedVideoIds([])
6473
queryClient.invalidateQueries({ queryKey: ['brand-collaborations'] })
6574
}
6675

@@ -86,12 +95,16 @@ export function CompetitorPage() {
8695
})
8796
}
8897

98+
/* Clear All: 선택 영상 + 분석 결과만 초기화 (검색 필터/영상 리스트 유지) */
8999
function handleClearSelection() {
90100
setSelectedVideoIds(new Set())
101+
setAnalyzedVideoIds([])
91102
}
92103

104+
/* '영상 분석하기' — 현재 선택 영상으로 trends 분석 트리거 */
93105
function handleAnalyze() {
94-
/* TODO: 분석 결과 페이지가 만들어지면 push 처리 (IN-270 범위 밖) */
106+
if (selectedVideoIds.size === 0) return
107+
setAnalyzedVideoIds(Array.from(selectedVideoIds))
95108
}
96109

97110
return (
@@ -101,11 +114,20 @@ export function CompetitorPage() {
101114
onChange={handleChange}
102115
onReset={handleReset}
103116
onSearch={handleSearch}
117+
isDetailOpen={isDetailOpen}
118+
onDetailOpenChange={setIsDetailOpen}
104119
/>
105120

106121
<div className='flex w-full flex-col gap-16 px-24 pt-24'>
107122
<AnalysisInsightCard hasResults={hasResults} />
108123

124+
{analyzedVideoIds.length > 0 && (
125+
<CompetitorInsightSection
126+
videoIds={analyzedVideoIds}
127+
onAnalysisComplete={() => setIsDetailOpen(false)}
128+
/>
129+
)}
130+
109131
{appliedFilter && (
110132
<>
111133
<CompetitorSelectionBar

src/shared/api/msw/handlers/brandCollaborationsHandlers.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,29 @@ import { mockBrandCollaborations } from '@/features/competitor/mock/mockBrandCol
44

55
const PAGE_SIZE = 9
66

7+
const mockTrendsKeywords = [
8+
'부스터 프로',
9+
'피부관리',
10+
'메디큐브',
11+
'광채피부',
12+
'홈케어',
13+
'모공관리',
14+
'리프팅',
15+
'내돈내산',
16+
'루틴',
17+
'전후비교',
18+
'피부장벽',
19+
'콜라겐',
20+
'뷰티디바이스',
21+
]
22+
723
export const brandCollaborationsHandlers = [
824
http.get(
925
`${process.env.NEXT_PUBLIC_API_URL}/brand-collaborations`,
1026
({ request }) => {
1127
const url = new URL(request.url)
1228
const cursor = url.searchParams.get('cursor')
13-
const pageSize =
14-
Number(url.searchParams.get('pageSize')) || PAGE_SIZE
29+
const pageSize = Number(url.searchParams.get('pageSize')) || PAGE_SIZE
1530
const sortCriteria = url.searchParams.get('sortCriteria') ?? 'LATEST'
1631
const sortOrder = url.searchParams.get('sortOrder') ?? 'DESC'
1732

@@ -43,4 +58,42 @@ export const brandCollaborationsHandlers = [
4358
})
4459
}
4560
),
61+
http.post(
62+
`${process.env.NEXT_PUBLIC_API_URL}/brand-collaborations/trends`,
63+
async ({ request }) => {
64+
const body = (await request.json()) as { youtubeVideoIds: string[] }
65+
/* 선택 영상 1개당 채널 1개 매칭으로 가정 */
66+
const channelCount = body.youtubeVideoIds.length
67+
68+
return HttpResponse.json({
69+
success: true,
70+
responseDto: {
71+
contentKeywords: {
72+
keywords: mockTrendsKeywords,
73+
keywordSummary:
74+
'3개 영상 모두 홈 피부관리 루틴 맥락에서 메디큐브 디바이스를 소개합니다. 특히 "내돈내산"처럼 자발적 구매처럼 보이는 연출이 반복되며, 사용 전·후 피부 비교 장면을 중심으로 제품 효과를 시각적으로 강조하는 구조가 공통적입니다.',
75+
},
76+
channelCharacteristics: {
77+
channelCount,
78+
avgSubscribers: 620000,
79+
minSubscribers: 50000,
80+
maxSubscribers: 3000000,
81+
uploadIntervalDays: 3.9,
82+
categoryDistribution: [
83+
{ category: '뷰티/스킨케어', percentage: 71.0 },
84+
{ category: '라이프 스타일', percentage: 18.0 },
85+
{ category: '건강/웰니스', percentage: 11.0 },
86+
],
87+
},
88+
strategyInsight: {
89+
pplIntent:
90+
'메디큐브는 병원 피부과의 홈케어 대체재라는 포지셔닝을 가집니다. 선정된 채널들은 구독자가 이미 피부 고민을 가진 20~30대 여성으로 구성되어 있어, 별도의 타겟팅 없이 높은 구매 의향층에 직접 도달할 수 있는 채널입니다. 특히 재협업 7개 채널은 초기 캠페인에서 전환율이 높았던 채널로, 지속 노출로 브랜드 신뢰도를 쌓는 전략으로 해석됩니다.',
91+
competitivePoints:
92+
'"병원 안 가도 되는 홈케어" 메시지가 핵심입니다. 전·후 비교 장면, 수치 기반 효과(콜라겐 200% 등), 크리에이터의 실제 피부 변화 서사를 통해 제품 신뢰를 구축합니다. 광고처럼 보이지 않는 "내돈내산" 연출 전략도 공통적으로 사용됩니다.',
93+
},
94+
},
95+
error: null,
96+
})
97+
}
98+
),
4699
]

src/shared/api/msw/handlers/videosHandlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const videosHandlers = [
4949
videos = videos.sort(sortFns[sort] ?? sortFns.LATEST)
5050

5151
return HttpResponse.json({
52-
isSuccess: true,
52+
success: true,
5353
responseDto: { ...basePage, videos },
5454
error: null,
5555
})

src/widgets/competitor/competitorFilter/ui/CompetitorFilterPanel.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState, useRef, useEffect } from 'react'
3+
import { useRef, useEffect, useState } from 'react'
44
import { cn } from '@/shared/lib/utils'
55
import { formatDate } from '@/shared/lib/format'
66
import { Button } from '@/shared/ui/button'
@@ -24,15 +24,26 @@ interface CompetitorFilterPanelProps {
2424
) => void
2525
onReset: () => void
2626
onSearch: () => void
27+
/* 상세 검색 영역 열림 상태 (외부 controlled 가능) — 미전달 시 내부 state로 동작 */
28+
isDetailOpen?: boolean
29+
onDetailOpenChange?: (open: boolean) => void
2730
}
2831

2932
export function CompetitorFilterPanel({
3033
filter,
3134
onChange,
3235
onReset,
3336
onSearch,
37+
isDetailOpen: isDetailOpenProp,
38+
onDetailOpenChange,
3439
}: CompetitorFilterPanelProps) {
35-
const [isDetailOpen, setIsDetailOpen] = useState(false)
40+
/* controlled/uncontrolled 모두 지원 */
41+
const [isDetailOpenInternal, setIsDetailOpenInternal] = useState(false)
42+
const isDetailOpen = isDetailOpenProp ?? isDetailOpenInternal
43+
const setIsDetailOpen = (next: boolean) => {
44+
onDetailOpenChange?.(next)
45+
if (isDetailOpenProp === undefined) setIsDetailOpenInternal(next)
46+
}
3647

3748
return (
3849
<div className='flex w-full flex-col gap-24 bg-background-gray-default py-24'>
@@ -102,7 +113,7 @@ export function CompetitorFilterPanel({
102113
<div className='flex justify-center'>
103114
<button
104115
type='button'
105-
onClick={() => setIsDetailOpen((prev) => !prev)}
116+
onClick={() => setIsDetailOpen(!isDetailOpen)}
106117
className='flex cursor-pointer items-center gap-4 rounded-full px-16 py-6 text-noto-label-md-normal text-text-and-icon-secondary transition-colors hover:bg-background-gray-default'>
107118
상세 검색
108119
<IconChevronDown
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { CompetitorInsightSection } from './ui/CompetitorInsightSection'

0 commit comments

Comments
 (0)