브랜드 분석, 콘텐츠 완료 페이지 수정 .
parent
b89cf027af
commit
e448d8189d
|
|
@ -5,6 +5,8 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>CASTAD</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon_32.svg" sizes="32x32">
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon_48.svg" sizes="48x48">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||
],
|
||||
"image_count": 119,
|
||||
"m_id": 1,
|
||||
"processed_info": {
|
||||
"customer_name": "풀스테이 스테이펫 홍천",
|
||||
"region": "",
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
|
||||
],
|
||||
"image_count": 119,
|
||||
"m_id": 1,
|
||||
"processed_info": {
|
||||
"customer_name": "Fullstay StayPet Hongcheon",
|
||||
"region": "",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1551_5349)">
|
||||
<rect width="32" height="32" fill="#046266"/>
|
||||
<path d="M10.8971 21.4823L12.5975 19.5992H22.2514L17.5498 9.98775L7.7726 21.4866H5.12L16.4318 8.2916C16.8059 7.858 17.3628 7.44141 18.0472 7.44141C18.7316 7.44141 19.1439 7.81549 19.3735 8.2916L25.92 21.4866H10.9013L10.8971 21.4823Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1551_5349">
|
||||
<rect width="32" height="32" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
|
|
@ -0,0 +1,11 @@
|
|||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1525_4684)">
|
||||
<rect width="48" height="48" fill="#046266"/>
|
||||
<path d="M16.3457 32.2216L18.8963 29.3968H33.3772L26.3248 14.9797L11.659 32.2279H7.68005L24.6478 12.4354C25.2089 11.785 26.0442 11.1602 27.0708 11.1602C28.0975 11.1602 28.716 11.7213 29.0603 12.4354L38.8801 32.2279H16.352L16.3457 32.2216Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1525_4684">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
|
|
@ -43,8 +43,8 @@
|
|||
"publishTimeLabel": "Publish Time",
|
||||
"publishNow": "Publish Now",
|
||||
"publishSchedule": "Schedule (Coming Soon)",
|
||||
"footerNote": "Posting is available after {link}.",
|
||||
"footerNoteLink": "plan upgrade",
|
||||
"footerNote": "",
|
||||
"footerNoteLink": "",
|
||||
"cancel": "Cancel",
|
||||
"posting": "Posting...",
|
||||
"post": "Post",
|
||||
|
|
@ -135,7 +135,6 @@
|
|||
"soundTypeLabel": "Select AI Sound Type",
|
||||
"soundTypeVocal": "Vocal",
|
||||
"soundTypeBGM": "Background Music",
|
||||
"soundTypeNarration": "Voice Narration",
|
||||
"genreLabel": "Select Genre",
|
||||
"genreAuto": "Auto Select",
|
||||
"genreBallad": "Ballad",
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@
|
|||
"publishTimeLabel": "게시 시간",
|
||||
"publishNow": "지금 게시",
|
||||
"publishSchedule": "시간 설정 (준비 중)",
|
||||
"footerNote": "게시는 {link} 후 가능합니다.",
|
||||
"footerNoteLink": "요금제 업그레이드",
|
||||
"footerNote": "",
|
||||
"footerNoteLink": "",
|
||||
"cancel": "취소",
|
||||
"posting": "게시 중...",
|
||||
"post": "게시",
|
||||
|
|
@ -135,7 +135,6 @@
|
|||
"soundTypeLabel": "AI 사운드 유형 선택",
|
||||
"soundTypeVocal": "보컬",
|
||||
"soundTypeBGM": "배경음악",
|
||||
"soundTypeNarration": "성우 내레이션",
|
||||
"genreLabel": "장르 선택",
|
||||
"genreAuto": "자동 선택",
|
||||
"genreBallad": "발라드",
|
||||
|
|
|
|||
|
|
@ -9,105 +9,13 @@ interface AnalysisResultSectionProps {
|
|||
data: CrawlingResponse;
|
||||
}
|
||||
|
||||
const SparklesIcon = ({ className = '' }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MapPinIcon = () => (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 22s7-6.1 7-12a7 7 0 10-14 0c0 5.9 7 12 7 12z" />
|
||||
<circle cx="12" cy="10" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
|
||||
// 범용 애니메이션 아이템 컴포넌트
|
||||
interface AnimatedItemProps {
|
||||
children: React.ReactNode;
|
||||
index: number;
|
||||
baseDelay?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AnimatedItem: React.FC<AnimatedItemProps> = ({ children, index, baseDelay = 0, className = '' }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const delay = baseDelay + index * 150;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transition-all duration-500 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 애니메이션 USP 아이템 컴포넌트
|
||||
interface AnimatedUSPItemProps {
|
||||
usp: SellingPoint;
|
||||
index: number;
|
||||
isTop?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedUSPItem: React.FC<AnimatedUSPItemProps> = ({ usp, index, isTop = false }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const delay = index * 150;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (isTop) {
|
||||
return (
|
||||
<div
|
||||
className={`bi-usp-top transition-all duration-500 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="bi-usp-category bi-usp-category-accent">{usp.english_category}</span>
|
||||
<span className="bi-usp-badge">TOP</span>
|
||||
</div>
|
||||
<div className="bi-usp-korean-category">{usp.korean_category}</div>
|
||||
<div className="bi-usp-description">{usp.description}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bi-usp-item transition-all duration-500 ${
|
||||
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
|
||||
}`}
|
||||
>
|
||||
<div className="bi-usp-english-category mb-2">{usp.english_category}</div>
|
||||
<div className="bi-usp-korean-category">{usp.korean_category}</div>
|
||||
<div className="bi-usp-description">{usp.description}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 레이더 차트 컴포넌트
|
||||
interface RadarChartProps {
|
||||
data: SellingPoint[];
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
||||
const RadarChart: React.FC<RadarChartProps> = ({ data, size = 360 }) => {
|
||||
const [animatedScores, setAnimatedScores] = useState<number[]>(data.map(() => 0));
|
||||
const [isAnimating, setIsAnimating] = useState(true);
|
||||
|
||||
|
|
@ -134,7 +42,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
requestAnimationFrame(animate);
|
||||
}, [data]);
|
||||
|
||||
const padding = 60;
|
||||
const padding = 80;
|
||||
const center = size / 2 + padding;
|
||||
const maxRadius = size / 2 - 20;
|
||||
const levels = 5;
|
||||
|
|
@ -149,15 +57,6 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
};
|
||||
};
|
||||
|
||||
const getLabelPosition = (index: number) => {
|
||||
const angle = angleStep * index - Math.PI / 2;
|
||||
const radius = maxRadius + 25;
|
||||
return {
|
||||
x: center + radius * Math.cos(angle),
|
||||
y: center + radius * Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
const dataPoints = animatedScores.map((score, i) => getPoint(i, score));
|
||||
const dataPath = dataPoints.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z';
|
||||
|
||||
|
|
@ -182,8 +81,17 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
|
||||
const svgSize = size + padding * 2;
|
||||
|
||||
// 순위별로 정렬하여 라벨 위치에 순위 번호 배치
|
||||
const sortedIndices = [...data]
|
||||
.map((item, i) => ({ index: i, score: item.score }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item, rank) => ({ ...item, rank: rank + 1 }));
|
||||
|
||||
const rankMap = new Map<number, number>();
|
||||
sortedIndices.forEach(item => rankMap.set(item.index, item.rank));
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-center items-center" style={{ overflow: 'visible' }}>
|
||||
<div className="bi2-radar-container">
|
||||
<svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
|
|
@ -195,7 +103,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
key={`level-${i}`}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
|
@ -205,7 +113,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
key={`axis-${i}`}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
|
@ -213,7 +121,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
<path
|
||||
d={dataPath}
|
||||
fill="rgba(45, 212, 191, 0.2)"
|
||||
stroke="#2dd4bf"
|
||||
stroke="#2DD4BF"
|
||||
strokeWidth="2"
|
||||
style={{
|
||||
transition: isAnimating ? 'none' : 'd 0.3s ease-out',
|
||||
|
|
@ -226,48 +134,62 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
|
|||
cx={point.x}
|
||||
cy={point.y}
|
||||
r="4"
|
||||
fill="#2dd4bf"
|
||||
stroke="#1a1a2e"
|
||||
fill="#2DD4BF"
|
||||
stroke="#1A1A2E"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.map((item, i) => {
|
||||
const pos = getLabelPosition(i);
|
||||
const isLeft = pos.x < center - 10;
|
||||
const isRight = pos.x > center + 10;
|
||||
const isTop = pos.y < center - 10;
|
||||
const isBottom = pos.y > center + 10;
|
||||
|
||||
let textAnchor: 'start' | 'middle' | 'end' = 'middle';
|
||||
let dy = 0;
|
||||
|
||||
if (isLeft) textAnchor = 'end';
|
||||
else if (isRight) textAnchor = 'start';
|
||||
|
||||
if (isTop && !isLeft && !isRight) dy = -8;
|
||||
else if (isBottom && !isLeft && !isRight) dy = 5;
|
||||
const angle = angleStep * i - Math.PI / 2;
|
||||
const labelRadius = maxRadius + 40;
|
||||
const pos = {
|
||||
x: center + labelRadius * Math.cos(angle),
|
||||
y: center + labelRadius * Math.sin(angle),
|
||||
};
|
||||
const rank = rankMap.get(i) || i + 1;
|
||||
const isTopThree = rank <= 3;
|
||||
|
||||
return (
|
||||
<g key={`label-${i}`}>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
dy={dy}
|
||||
textAnchor={textAnchor}
|
||||
className="bi-chart-label"
|
||||
>
|
||||
{item.korean_category}
|
||||
</text>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
dy={dy + 16}
|
||||
textAnchor={textAnchor}
|
||||
className="bi-chart-sublabel"
|
||||
>
|
||||
{item.english_category}
|
||||
</text>
|
||||
<g transform={`translate(${pos.x}, ${pos.y})`}>
|
||||
<rect
|
||||
x="-10"
|
||||
y="-10"
|
||||
width="20"
|
||||
height="20"
|
||||
rx="4"
|
||||
fill={isTopThree ? '#94FBE0' : '#206764'}
|
||||
/>
|
||||
<text
|
||||
x="0"
|
||||
y="0"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
fill: isTopThree ? '#002224' : '#94FBE0',
|
||||
fontFamily: 'Pretendard',
|
||||
}}
|
||||
>
|
||||
{rank}
|
||||
</text>
|
||||
<text
|
||||
x="16"
|
||||
y="0"
|
||||
textAnchor="start"
|
||||
dominantBaseline="central"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 400,
|
||||
fill: '#E5F1F2',
|
||||
fontFamily: 'Pretendard',
|
||||
}}
|
||||
>
|
||||
{item.korean_category}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
|
@ -288,6 +210,9 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
const sellingPoints = marketing_analysis?.selling_points || [];
|
||||
const targetKeywords = marketing_analysis?.target_keywords || [];
|
||||
|
||||
// 셀링 포인트를 score 내림차순으로 정렬
|
||||
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
|
||||
|
||||
useEffect(() => {
|
||||
const updateButtonPosition = () => {
|
||||
if (containerRef.current) {
|
||||
|
|
@ -308,15 +233,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
};
|
||||
}, []);
|
||||
|
||||
const topUSP = sellingPoints.length > 0
|
||||
? [...sellingPoints].sort((a, b) => b.score - a.score)[0]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
||||
<div className="bi2-page">
|
||||
{/* Header */}
|
||||
<div className="asset-header">
|
||||
<button onClick={onBack} className="btn-back-new">
|
||||
<div className="bi2-header">
|
||||
<button onClick={onBack} className="bi2-back-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
|
|
@ -324,158 +245,124 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="bi-page-header">
|
||||
<div className="bi-page-icon">
|
||||
<img src="/assets/images/star-icon.svg" alt="Star" className="bi-star-icon" />
|
||||
{/* Page Title */}
|
||||
<div className="bi2-page-title-section">
|
||||
<div className="bi2-title-icon">
|
||||
<img src="/assets/images/star-icon.svg" alt="" className="bi2-star-icon" />
|
||||
</div>
|
||||
<div className="bi2-title-text">
|
||||
<h1 className="bi2-main-title">{t('analysis.pageTitle')}</h1>
|
||||
<p className="bi2-subtitle">
|
||||
{t('analysis.pageDescHighlight')}{t('analysis.pageDescBefore')}{processed_info?.customer_name || t('analysis.defaultBrandName')}{t('analysis.pageDescAfter')}
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="bi-page-title">{t('analysis.pageTitle')}</h1>
|
||||
<p className="bi-page-desc">
|
||||
<span className="highlight">{t('analysis.pageDescHighlight')}</span>{t('analysis.pageDescBefore')}{processed_info?.customer_name || t('analysis.defaultBrandName')}{t('analysis.pageDescAfter')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div ref={containerRef} className="max-w-[1440px] mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* 왼쪽 컬럼 */}
|
||||
<div className="space-y-6">
|
||||
{/* 브랜드 정체성 카드 */}
|
||||
<div className="bi-card relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-[#2dd4bf] to-[#AE72F9]"></div>
|
||||
{/* Main Content Container */}
|
||||
<div ref={containerRef} className="bi2-main-container">
|
||||
{/* 매장명 & 주소 */}
|
||||
<div className="bi2-store-header">
|
||||
<h2 className="bi2-store-name">{processed_info?.customer_name || t('analysis.brandNameFallback')}</h2>
|
||||
<p className="bi2-store-address">{processed_info?.detail_region_info || t('analysis.addressFallback')}</p>
|
||||
</div>
|
||||
|
||||
<div className="bi-section-label mb-4">
|
||||
{t('analysis.brandIdentity')}
|
||||
</div>
|
||||
|
||||
<h2 className="bi-brand-name">{processed_info?.customer_name || t('analysis.brandNameFallback')}</h2>
|
||||
|
||||
<div className="bi-location mb-6">
|
||||
<MapPinIcon />
|
||||
<div>
|
||||
<p>{processed_info?.detail_region_info || t('analysis.addressFallback')}</p>
|
||||
<p style={{ opacity: 0.7, marginTop: '4px' }}>{processed_info?.region || ''}</p>
|
||||
{/* 브랜드 정체성 */}
|
||||
<div className="bi2-section">
|
||||
<h3 className="bi2-section-title">{t('analysis.brandIdentity')}</h3>
|
||||
<div className="bi2-identity-card">
|
||||
<div className="bi2-identity-top">
|
||||
<div className="bi2-identity-core">
|
||||
<span className="bi2-label-sm">{t('analysis.coreValue')}</span>
|
||||
<p className="bi2-core-value">{marketPositioning?.core_value || t('analysis.noInfo')}</p>
|
||||
</div>
|
||||
<p className="bi2-category-text">{t('analysis.categoryDefinition')} : {marketPositioning?.category_definition || t('analysis.noInfo')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 border-t border-white/5 pt-6">
|
||||
<AnimatedItem index={0} baseDelay={100}>
|
||||
<div className="bi-subsection-title">{t('analysis.locationAnalysis')}</div>
|
||||
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || t('analysis.noInfo')}</p>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={100}>
|
||||
<div className="bi-subsection-title">{t('analysis.conceptScalability')}</div>
|
||||
<p className="bi-body-text">{brandIdentity?.concept_scalability || t('analysis.noInfo')}</p>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 시장 포지셔닝 카드 */}
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
{t('analysis.marketPositioning')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<AnimatedItem index={0} baseDelay={200}>
|
||||
<div className="bi-inner-box bi-inner-box-accent">
|
||||
<div className="bi-subsection-title">{t('analysis.coreValue')}</div>
|
||||
<div className="bi-value">{marketPositioning?.core_value || t('analysis.noInfo')}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
<AnimatedItem index={1} baseDelay={200}>
|
||||
<div className="bi-inner-box">
|
||||
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>{t('analysis.categoryDefinition')}</div>
|
||||
<div className="bi-value">{marketPositioning?.category_definition || t('analysis.noInfo')}</div>
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 페르소나 카드 */}
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">
|
||||
{t('analysis.targetPersona')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{targetPersonas.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</p>}
|
||||
{targetPersonas.map((persona: TargetPersona, idx: number) => (
|
||||
<AnimatedItem key={idx} index={idx} baseDelay={300}>
|
||||
<div className="bi-inner-box">
|
||||
<div className="mb-4">
|
||||
<div className="bi-persona-name">{persona.persona}</div>
|
||||
<div className="bi-persona-age">{persona.age.min_age}~{persona.age.max_age}{t('analysis.ageSuffix')}</div>
|
||||
</div>
|
||||
|
||||
{persona.favor_target.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{persona.favor_target.map((favor: string, i: number) => (
|
||||
<span key={i} className="bi-tag">{favor}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{persona.decision_trigger && (
|
||||
<p className="bi-persona-trigger">
|
||||
<strong>Trigger:</strong> {persona.decision_trigger}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedItem>
|
||||
))}
|
||||
<div className="bi2-identity-divider"></div>
|
||||
<div className="bi2-identity-bottom">
|
||||
<div className="bi2-identity-col">
|
||||
<span className="bi2-label-sm">{t('analysis.locationAnalysis')}</span>
|
||||
<p className="bi2-body-text">{brandIdentity?.location_feature_analysis || t('analysis.noInfo')}</p>
|
||||
</div>
|
||||
<div className="bi2-identity-col">
|
||||
<span className="bi2-label-sm">{t('analysis.conceptScalability')}</span>
|
||||
<p className="bi2-body-text">{brandIdentity?.concept_scalability || t('analysis.noInfo')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 컬럼 */}
|
||||
<div className="space-y-6">
|
||||
{/* 주요 셀링 포인트 카드 */}
|
||||
<div className="bi-card min-h-[500px] flex flex-col">
|
||||
<h3 className="bi-card-title mb-6">
|
||||
{t('analysis.sellingPoints')}
|
||||
</h3>
|
||||
|
||||
{/* 주요 셀링 포인트 */}
|
||||
<div className="bi2-section">
|
||||
<h3 className="bi2-section-title">{t('analysis.sellingPoints')}</h3>
|
||||
<div className="bi2-selling-card">
|
||||
{/* 레이더 차트 */}
|
||||
{sellingPoints.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<RadarChart data={sellingPoints} size={280} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top USP 하이라이트 */}
|
||||
{topUSP && (
|
||||
<AnimatedUSPItem
|
||||
usp={topUSP}
|
||||
index={0}
|
||||
isTop={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 나머지 USP 리스트 */}
|
||||
<div className="space-y-4 flex-1">
|
||||
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</p>}
|
||||
{sellingPoints
|
||||
.filter((usp: SellingPoint) => usp.english_category !== topUSP?.english_category)
|
||||
.map((usp: SellingPoint, idx: number) => (
|
||||
<AnimatedUSPItem
|
||||
key={idx}
|
||||
usp={usp}
|
||||
index={idx + 1}
|
||||
isTop={false}
|
||||
/>
|
||||
))}
|
||||
<div className="bi2-selling-chart">
|
||||
{sellingPoints.length > 0 && (
|
||||
<RadarChart data={sellingPoints} size={360} />
|
||||
)}
|
||||
</div>
|
||||
{/* 셀링 포인트 리스트 */}
|
||||
<div className="bi2-selling-list">
|
||||
{sortedSellingPoints.map((sp, idx) => {
|
||||
const rank = idx + 1;
|
||||
const isTopThree = rank <= 3;
|
||||
return (
|
||||
<div key={idx} className="bi2-selling-item">
|
||||
<div className={`bi2-rank-badge ${isTopThree ? 'top' : ''}`}>
|
||||
{rank}
|
||||
</div>
|
||||
<div className="bi2-selling-item-text">
|
||||
<span className="bi2-selling-name">{sp.korean_category}</span>
|
||||
<span className="bi2-selling-desc">{sp.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추천 타겟 키워드 */}
|
||||
<div className="max-w-[1440px] mx-auto px-4 md:px-8 mt-10">
|
||||
<div className="bi-card">
|
||||
<h3 className="bi-card-title">{t('analysis.recommendedKeywords')}</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</span>}
|
||||
{/* 주요 고객 유형 */}
|
||||
<div className="bi2-section">
|
||||
<h3 className="bi2-section-title">{t('analysis.targetPersona')}</h3>
|
||||
<div className="bi2-persona-grid">
|
||||
{targetPersonas.map((persona: TargetPersona, idx: number) => (
|
||||
<div key={idx} className="bi2-persona-card">
|
||||
<div className="bi2-persona-header">
|
||||
<div className="bi2-persona-info">
|
||||
<span className="bi2-persona-name">{persona.persona}</span>
|
||||
<span className="bi2-persona-age">{persona.age.min_age}~{persona.age.max_age}{t('analysis.ageSuffix')}</span>
|
||||
</div>
|
||||
<p className="bi2-persona-desc">
|
||||
{persona.decision_trigger}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bi2-persona-divider"></div>
|
||||
<div className="bi2-persona-detail grow">
|
||||
<span className="bi2-label-xs">{t('analysis.favorKeywords', { defaultValue: '선호 키워드' })}</span>
|
||||
<p className="bi2-persona-detail-text">
|
||||
{persona.favor_target.join('\n')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bi2-persona-divider"></div>
|
||||
<div className="bi2-persona-detail">
|
||||
<span className="bi2-label-xs">{t('analysis.decisionTrigger', { defaultValue: '예약 결정 포인트' })}</span>
|
||||
<p className="bi2-persona-detail-text">{persona.decision_trigger}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추천 타겟 키워드 */}
|
||||
<div className="bi2-section">
|
||||
<div className="bi2-keyword-header">
|
||||
<h3 className="bi2-section-title">{t('analysis.recommendedKeywords')}</h3>
|
||||
<p className="bi2-keyword-subtitle">{t('analysis.keywordHint', { defaultValue: '이런 키워드로 찾을 가능성이 높아요' })}</p>
|
||||
</div>
|
||||
<div className="bi2-keyword-tags">
|
||||
{targetKeywords.map((keyword: string, idx: number) => (
|
||||
<span key={idx} className="bi-tag-outline"># {keyword}</span>
|
||||
<span key={idx} className="bi2-keyword-pill">#{keyword}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -490,7 +377,9 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
|
|||
onClick={onGenerate}
|
||||
className="pointer-events-auto bg-brand-purple hover:bg-brand-purpleHover text-white font-bold py-3 px-12 rounded-full shadow-2xl shadow-brand-purple/40 transform hover:scale-105 transition-all duration-300 flex items-center gap-2"
|
||||
>
|
||||
<SparklesIcon className="w-5 h-5" />
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden className="w-5 h-5">
|
||||
<path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
|
||||
</svg>
|
||||
{t('analysis.generateContent')}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { generateVideo, waitForVideoComplete, getYouTubeConnectUrl, getSocialAccounts, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api';
|
||||
import { SocialAccount } from '../../types/api';
|
||||
import { generateVideo, waitForVideoComplete } from '../../utils/api';
|
||||
import SocialPostingModal from '../../components/SocialPostingModal';
|
||||
|
||||
interface CompletionContentProps {
|
||||
onBack: () => void;
|
||||
|
|
@ -12,10 +12,9 @@ interface CompletionContentProps {
|
|||
}
|
||||
|
||||
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
|
||||
|
||||
const VIDEO_STORAGE_KEY = 'castad_video_generation';
|
||||
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 (만료 없음)
|
||||
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분
|
||||
const VIDEO_COMPLETE_KEY = 'castad_video_complete';
|
||||
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000;
|
||||
|
||||
interface SavedVideoState {
|
||||
videoTaskId: string;
|
||||
|
|
@ -32,23 +31,31 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
onVideoProgressChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
|
||||
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [renderProgress, setRenderProgress] = useState(0); // 영상 렌더링 진행률 (0-100)
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hasStartedGeneration = useRef(false);
|
||||
const hideControlsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// YouTube 연결 상태
|
||||
const [youtubeAccount, setYoutubeAccount] = useState<SocialAccount | null>(null);
|
||||
const [isYoutubeConnecting, setIsYoutubeConnecting] = useState(false);
|
||||
const [youtubeError, setYoutubeError] = useState<string | null>(null);
|
||||
// 소셜 미디어 포스팅 모달
|
||||
const [showSocialModal, setShowSocialModal] = useState(false);
|
||||
|
||||
// 저장된 완료 데이터
|
||||
const [songCompletionData, setSongCompletionData] = useState<{
|
||||
businessName: string;
|
||||
genre: string;
|
||||
lyrics: string;
|
||||
} | null>(null);
|
||||
|
||||
// 비디오 비율
|
||||
const [videoRatio, setVideoRatio] = useState<'vertical' | 'horizontal'>('vertical');
|
||||
|
||||
// Notify parent of video status changes
|
||||
useEffect(() => {
|
||||
if (onVideoStatusChange) {
|
||||
const mappedStatus = videoStatus === 'polling' ? 'generating' : videoStatus;
|
||||
|
|
@ -56,7 +63,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
}, [videoStatus, onVideoStatusChange]);
|
||||
|
||||
// Notify parent of progress changes
|
||||
useEffect(() => {
|
||||
if (onVideoProgressChange) {
|
||||
onVideoProgressChange(renderProgress);
|
||||
|
|
@ -73,7 +79,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
};
|
||||
localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data));
|
||||
|
||||
// 완료된 경우 별도의 만료 없는 키에도 저장
|
||||
if (status === 'complete' && url) {
|
||||
const completeData = {
|
||||
songTaskId: currentSongTaskId,
|
||||
|
|
@ -81,16 +86,13 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
completedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(VIDEO_COMPLETE_KEY, JSON.stringify(completeData));
|
||||
console.log('[Video] Saved complete state:', completeData);
|
||||
}
|
||||
};
|
||||
|
||||
const clearStorage = () => {
|
||||
localStorage.removeItem(VIDEO_STORAGE_KEY);
|
||||
// 주의: VIDEO_COMPLETE_KEY는 여기서 삭제하지 않음 (명시적으로만 삭제)
|
||||
};
|
||||
|
||||
// 완료된 영상 정보 로드 (만료 없음)
|
||||
const loadCompleteVideo = (): { songTaskId: string; videoUrl: string } | null => {
|
||||
try {
|
||||
const saved = localStorage.getItem(VIDEO_COMPLETE_KEY);
|
||||
|
|
@ -129,7 +131,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Get video ratio from localStorage (default to 'vertical' if not set)
|
||||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||||
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
|
||||
|
||||
|
|
@ -141,7 +142,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
|
||||
setVideoStatus('polling');
|
||||
setStatusMessage(t('completion.generatingVideo'));
|
||||
// video/status API는 creatomate_render_id를 사용
|
||||
saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null);
|
||||
|
||||
await pollVideoStatus(videoResponse.creatomate_render_id, songTaskId);
|
||||
|
|
@ -155,7 +155,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 상태별 한글 메시지 및 진행률 매핑
|
||||
const getStatusMessage = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
|
|
@ -173,7 +172,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 상태별 진행률 계산
|
||||
const getProgressForStatus = (status: string): number => {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
|
|
@ -193,7 +191,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
|
||||
const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => {
|
||||
try {
|
||||
// 영상 생성 상태 폴링 (3분 타임아웃, 3초 간격)
|
||||
const statusResponse = await waitForVideoComplete(
|
||||
videoTaskId,
|
||||
(status: string) => {
|
||||
|
|
@ -202,7 +199,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
);
|
||||
|
||||
// render_data.url에서 영상 URL 가져오기
|
||||
const videoUrlFromResponse = statusResponse.render_data?.url;
|
||||
|
||||
if (videoUrlFromResponse) {
|
||||
|
|
@ -230,178 +226,65 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
|
||||
useEffect(() => {
|
||||
if (!songTaskId) return;
|
||||
|
||||
// 1. 먼저 완료된 영상 정보 확인 (만료 없음)
|
||||
const completeVideo = loadCompleteVideo();
|
||||
if (completeVideo && completeVideo.songTaskId === songTaskId && completeVideo.videoUrl) {
|
||||
console.log('[Video] Restored from complete storage:', completeVideo);
|
||||
setVideoUrl(completeVideo.videoUrl);
|
||||
setVideoStatus('complete');
|
||||
hasStartedGeneration.current = true;
|
||||
return; // 완료된 영상이 있으면 더 이상 진행하지 않음
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 일반 저장소에서 상태 확인 (30분 만료)
|
||||
const savedState = loadFromStorage();
|
||||
|
||||
// 저장된 상태가 있고, 같은 songTaskId인 경우
|
||||
if (savedState && savedState.songTaskId === songTaskId) {
|
||||
if (savedState.status === 'complete' && savedState.videoUrl) {
|
||||
// 이미 완료된 경우
|
||||
setVideoUrl(savedState.videoUrl);
|
||||
setVideoStatus('complete');
|
||||
hasStartedGeneration.current = true;
|
||||
} else if (savedState.status === 'polling') {
|
||||
// 폴링 중이었던 경우 다시 폴링
|
||||
setVideoStatus('polling');
|
||||
setStatusMessage(t('completion.processingAfterRefresh'));
|
||||
hasStartedGeneration.current = true;
|
||||
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
|
||||
}
|
||||
} else if (!hasStartedGeneration.current) {
|
||||
// 새로운 영상 생성 시작
|
||||
startVideoGeneration();
|
||||
}
|
||||
}, [songTaskId]);
|
||||
|
||||
// localStorage에서 방금 연결된 계정 정보 확인
|
||||
const SOCIAL_CONNECTED_KEY = 'castad_social_connected';
|
||||
|
||||
const checkLocalStorageForConnectedAccount = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(SOCIAL_CONNECTED_KEY);
|
||||
if (saved) {
|
||||
const connectedAccount = JSON.parse(saved);
|
||||
console.log('[YouTube] Found connected account in localStorage:', connectedAccount);
|
||||
|
||||
if (connectedAccount.platform === 'youtube') {
|
||||
// localStorage에서 계정 정보로 즉시 UI 업데이트
|
||||
setYoutubeAccount({
|
||||
id: parseInt(connectedAccount.account_id, 10),
|
||||
platform: 'youtube',
|
||||
platform_user_id: connectedAccount.account_id,
|
||||
platform_username: connectedAccount.channel_name,
|
||||
display_name: connectedAccount.channel_name,
|
||||
is_active: true,
|
||||
connected_at: connectedAccount.connected_at,
|
||||
profile_image: connectedAccount.profile_image,
|
||||
});
|
||||
|
||||
// localStorage에서 제거 (일회성 사용)
|
||||
localStorage.removeItem(SOCIAL_CONNECTED_KEY);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[YouTube] Failed to parse localStorage:', error);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// YouTube 연결 상태 확인 (API 호출 - /social/oauth/accounts)
|
||||
const checkYoutubeConnection = async () => {
|
||||
try {
|
||||
console.log('[YouTube] Checking connection status from API...');
|
||||
const response = await getSocialAccounts();
|
||||
console.log('[YouTube] Accounts response:', response);
|
||||
|
||||
// accounts 배열에서 youtube 플랫폼 찾기
|
||||
const youtubeAcc = response.accounts?.find(acc => acc.platform === 'youtube' && acc.is_active);
|
||||
|
||||
if (youtubeAcc) {
|
||||
setYoutubeAccount(youtubeAcc);
|
||||
console.log('[YouTube] Account connected:', youtubeAcc.display_name);
|
||||
} else {
|
||||
setYoutubeAccount(null);
|
||||
console.log('[YouTube] No YouTube account connected');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('[YouTube] Failed to check connection:', error);
|
||||
// API 에러 시에도 localStorage에서 확인한 값 유지
|
||||
}
|
||||
};
|
||||
|
||||
// 완료 데이터 로드
|
||||
useEffect(() => {
|
||||
// 먼저 localStorage에서 방금 연결된 계정 확인
|
||||
const foundInLocalStorage = checkLocalStorageForConnectedAccount();
|
||||
try {
|
||||
const saved = localStorage.getItem('castad_song_completion');
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
setSongCompletionData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load song completion data:', error);
|
||||
}
|
||||
|
||||
// localStorage에 없으면 API로 확인
|
||||
if (!foundInLocalStorage) {
|
||||
checkYoutubeConnection();
|
||||
} else {
|
||||
// localStorage에서 찾았어도 API로 최신 정보 갱신 (백그라운드)
|
||||
checkYoutubeConnection();
|
||||
// 비디오 비율 로드
|
||||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||||
if (savedRatio === 'horizontal' || savedRatio === 'vertical') {
|
||||
setVideoRatio(savedRatio);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// YouTube 연결 핸들러
|
||||
const handleYoutubeConnect = async () => {
|
||||
if (youtubeAccount) {
|
||||
// 이미 연결된 경우 선택/해제만 토글
|
||||
toggleSocial('Youtube');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsYoutubeConnecting(true);
|
||||
setYoutubeError(null);
|
||||
|
||||
try {
|
||||
const response = await getYouTubeConnectUrl();
|
||||
// OAuth URL로 리다이렉트
|
||||
window.location.href = response.auth_url;
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('YouTube connect failed:', error);
|
||||
setYoutubeError(t('completion.youtubeConnectFailed'));
|
||||
setIsYoutubeConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// YouTube 연결 해제 핸들러
|
||||
const handleYoutubeDisconnect = async () => {
|
||||
if (!youtubeAccount) return;
|
||||
|
||||
try {
|
||||
await disconnectSocialAccount(youtubeAccount.id);
|
||||
setYoutubeAccount(null);
|
||||
setSelectedSocials(prev => prev.filter(s => s !== 'Youtube'));
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
alert(t('completion.youtubeExpiredAlert'));
|
||||
handleSocialReconnect(error.reconnectUrl);
|
||||
return;
|
||||
}
|
||||
console.error('YouTube disconnect failed:', error);
|
||||
setYoutubeError(t('completion.disconnectFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSocial = (id: string) => {
|
||||
setSelectedSocials(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(s => s !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const togglePlayPause = () => {
|
||||
if (!videoRef.current || !videoUrl) return;
|
||||
if (isPlaying) {
|
||||
videoRef.current.pause();
|
||||
setShowControls(true);
|
||||
if (hideControlsTimer.current) clearTimeout(hideControlsTimer.current);
|
||||
} else {
|
||||
videoRef.current.play();
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
|
@ -415,6 +298,36 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
const handleVideoEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setShowControls(true);
|
||||
};
|
||||
|
||||
const resetHideControlsTimer = () => {
|
||||
if (hideControlsTimer.current) {
|
||||
clearTimeout(hideControlsTimer.current);
|
||||
}
|
||||
setShowControls(true);
|
||||
if (isPlaying) {
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoMouseEnter = () => {
|
||||
resetHideControlsTimer();
|
||||
};
|
||||
|
||||
const handleVideoMouseMove = () => {
|
||||
resetHideControlsTimer();
|
||||
};
|
||||
|
||||
const handleVideoMouseLeave = () => {
|
||||
if (isPlaying) {
|
||||
if (hideControlsTimer.current) clearTimeout(hideControlsTimer.current);
|
||||
hideControlsTimer.current = setTimeout(() => {
|
||||
setShowControls(false);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
|
|
@ -429,27 +342,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 소셜 채널에 배포
|
||||
const handleDeploy = () => {
|
||||
if (selectedSocials.length === 0) {
|
||||
alert(t('completion.selectSocialChannel'));
|
||||
return;
|
||||
}
|
||||
const handleOpenSocialConnect = () => {
|
||||
setShowSocialModal(true);
|
||||
};
|
||||
|
||||
if (!videoUrl) {
|
||||
alert(t('completion.videoNotReady'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 선택된 채널 중 YouTube가 있고 연결된 경우
|
||||
if (selectedSocials.includes('Youtube') && youtubeAccount) {
|
||||
// TODO: YouTube 업로드 API 호출
|
||||
alert(t('completion.youtubeUploadMessage', { channelName: youtubeAccount.display_name }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 다른 플랫폼 선택 시
|
||||
alert(t('completion.deployComingSoon'));
|
||||
const handleCloseSocialConnect = () => {
|
||||
setShowSocialModal(false);
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
|
|
@ -461,224 +359,173 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
|
|||
startVideoGeneration();
|
||||
};
|
||||
|
||||
const socials = [
|
||||
{
|
||||
id: 'Youtube',
|
||||
logo: '/assets/images/social-youtube.png',
|
||||
connected: !!youtubeAccount,
|
||||
channelName: youtubeAccount?.display_name || null,
|
||||
thumbnailUrl: youtubeAccount?.profile_image || null,
|
||||
isConnecting: isYoutubeConnecting,
|
||||
},
|
||||
{ id: 'Instagram', logo: '/assets/images/social-instagram.png', connected: false, channelName: null, thumbnailUrl: null, isConnecting: false },
|
||||
{ id: 'Facebook', logo: '/assets/images/social-facebook.png', connected: false, channelName: null, thumbnailUrl: null, isConnecting: false },
|
||||
];
|
||||
|
||||
const isLoading = videoStatus === 'generating' || videoStatus === 'polling';
|
||||
|
||||
// 비디오 해상도 계산
|
||||
const getVideoResolution = () => {
|
||||
const savedRatio = localStorage.getItem('castad_video_ratio');
|
||||
return savedRatio === 'horizontal' ? '1234×720' : '720×1234';
|
||||
};
|
||||
|
||||
// 파일명 생성
|
||||
const getFileName = () => {
|
||||
const businessName = songCompletionData?.businessName || '콘텐츠';
|
||||
return `${businessName}.mp4`;
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="page-container">
|
||||
{/* Header with Back Button */}
|
||||
<div className="asset-header">
|
||||
<button onClick={onBack} className="btn-back-new">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span>{t('completion.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<main className="comp2-page">
|
||||
<div className="comp2-header">
|
||||
<button onClick={onBack} className="comp2-back-btn">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
</svg>
|
||||
<span>{t('completion.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="completion-title">
|
||||
{isLoading ? t('completion.titleGenerating') : videoStatus === 'error' ? t('completion.titleError') : t('completion.titleComplete')}
|
||||
</h1>
|
||||
<div className="comp2-title-row">
|
||||
<h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
|
||||
<p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content Container */}
|
||||
<div className="completion-container">
|
||||
{/* Left: Video Preview */}
|
||||
<div className="completion-column completion-column-left video-preview-card">
|
||||
<h3 className="asset-section-title">{t('completion.imageAndVideo')}</h3>
|
||||
|
||||
<div className="completion-video-wrapper">
|
||||
<div className="video-container">
|
||||
{isLoading ? (
|
||||
/* Loading State */
|
||||
<div className="video-loading">
|
||||
<div className="loading-spinner">
|
||||
<div className="loading-ring"></div>
|
||||
<div className="loading-dot">
|
||||
<div className="loading-dot-inner"></div>
|
||||
<div className="comp2-container">
|
||||
<div className="comp2-grid">
|
||||
{/* 왼쪽: 영상 */}
|
||||
<div className="comp2-video-section">
|
||||
<div className="comp2-video-wrapper" onMouseEnter={handleVideoMouseEnter} onMouseMove={handleVideoMouseMove} onMouseLeave={handleVideoMouseLeave}>
|
||||
{isLoading ? (
|
||||
<div className="comp2-video-loading">
|
||||
<div className="loading-spinner">
|
||||
<div className="loading-ring"></div>
|
||||
<div className="loading-dot">
|
||||
<div className="loading-dot-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="comp2-loading-text">{statusMessage}</p>
|
||||
</div>
|
||||
<p className="text-gray-400 mt-4">{statusMessage}</p>
|
||||
</div>
|
||||
) : videoStatus === 'error' ? (
|
||||
/* Error State */
|
||||
<div className="video-error">
|
||||
<svg className="w-16 h-16 text-red-500 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<p className="text-gray-400 mb-4">{errorMessage}</p>
|
||||
<button onClick={handleRetry} className="btn-secondary">
|
||||
{t('completion.retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
<>
|
||||
{/* Video Player */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="video-player"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleVideoEnded}
|
||||
onClick={togglePlayPause}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="video-pattern"></div>
|
||||
)}
|
||||
|
||||
{/* Video Player Controls - only show when video is ready */}
|
||||
{videoStatus === 'complete' && videoUrl && (
|
||||
<div className="video-controls">
|
||||
<div className="video-controls-inner">
|
||||
<button className="video-play-btn" onClick={togglePlayPause}>
|
||||
{isPlaying ? (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||
) : (
|
||||
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
)}
|
||||
) : videoStatus === 'error' ? (
|
||||
<div className="comp2-video-error">
|
||||
<svg className="comp2-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4M12 16h.01" />
|
||||
</svg>
|
||||
<p className="comp2-error-text">{errorMessage}</p>
|
||||
<button onClick={handleRetry} className="comp2-retry-btn">
|
||||
{t('completion.retry')}
|
||||
</button>
|
||||
<div className="video-progress">
|
||||
<div className="video-progress-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
) : videoUrl ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className="comp2-video-player"
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleVideoEnded}
|
||||
onClick={togglePlayPause}
|
||||
/>
|
||||
<div className={`comp2-video-controls ${showControls ? 'visible' : 'hidden'}`}>
|
||||
<div className="comp2-progress-bar">
|
||||
<div className="comp2-progress-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<div className="comp2-controls-row">
|
||||
<button className="comp2-play-btn" onClick={togglePlayPause}>
|
||||
{isPlaying ? (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<div className="comp2-volume-control">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
<div className="comp2-volume-bar">
|
||||
<div className="comp2-volume-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="comp2-time-display">
|
||||
<span className="comp2-time-current">00:04</span>
|
||||
<span className="comp2-time-divider">/</span>
|
||||
<span className="comp2-time-total">00:34</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Optimization Tags - only show when complete */}
|
||||
{videoStatus === 'complete' && (
|
||||
<div className="ai-optimization-section">
|
||||
<h3 className="ai-optimization-title">{t('completion.aiOptimization')}</h3>
|
||||
<div className="ai-optimization-tags">
|
||||
{[
|
||||
{ key: 'colorCorrection', label: t('completion.aiTagColorCorrection') },
|
||||
{ key: 'dynamicSubtitle', label: t('completion.aiTagDynamicSubtitle') },
|
||||
{ key: 'beatSync', label: t('completion.aiTagBeatSync') },
|
||||
{ key: 'seoMeta', label: t('completion.aiTagSEOMeta') },
|
||||
].map(tag => (
|
||||
<div key={tag.key} className="ai-tag">
|
||||
<div className="ai-tag-dot"></div>
|
||||
<span className="ai-tag-text">{tag.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="comp2-video-placeholder"></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Sharing */}
|
||||
<div className="completion-column completion-column-right sharing-card">
|
||||
<div className="sharing-content">
|
||||
<h3 className="asset-section-title">{t('completion.sharing')}</h3>
|
||||
|
||||
<div className="social-list-new">
|
||||
{socials.map(social => {
|
||||
const isSelected = selectedSocials.includes(social.id);
|
||||
const isYoutube = social.id === 'Youtube';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={social.id}
|
||||
onClick={() => {
|
||||
if (videoStatus !== 'complete') return;
|
||||
if (isYoutube) {
|
||||
handleYoutubeConnect();
|
||||
} else {
|
||||
// Instagram, Facebook은 아직 미구현
|
||||
}
|
||||
}}
|
||||
className={`completion-social-card ${videoStatus !== 'complete' ? 'disabled' : ''} ${social.connected ? 'connected' : ''} ${isSelected ? 'selected' : ''}`}
|
||||
>
|
||||
<div className="completion-social-info">
|
||||
{/* 연결된 경우 채널 썸네일 표시, 아니면 플랫폼 로고 */}
|
||||
{social.connected && social.thumbnailUrl ? (
|
||||
<img src={social.thumbnailUrl} alt={social.channelName || social.id} className="completion-social-thumbnail" />
|
||||
) : (
|
||||
<img src={social.logo} alt={social.id} className="completion-social-logo" />
|
||||
)}
|
||||
<div className="completion-social-text">
|
||||
{social.connected && social.channelName ? (
|
||||
<>
|
||||
<span className="completion-social-channel-name">{social.channelName}</span>
|
||||
<span className="completion-social-platform">{social.id}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="completion-social-name">{social.id}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{social.isConnecting ? (
|
||||
<span className="completion-social-status connecting">{t('completion.connecting')}</span>
|
||||
) : social.connected ? (
|
||||
<div className="completion-social-actions">
|
||||
<span className="completion-social-status connected">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
{t('completion.authenticated')}
|
||||
</span>
|
||||
{isYoutube && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleYoutubeDisconnect();
|
||||
}}
|
||||
className="completion-social-disconnect"
|
||||
>
|
||||
{t('completion.disconnect')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="completion-social-status not-connected">
|
||||
{isYoutube ? t('completion.connectAccount') : t('completion.comingSoon')}
|
||||
</span>
|
||||
)}
|
||||
{/* 오른쪽: 콘텐츠 정보 */}
|
||||
<div className="comp2-info-section">
|
||||
<div className="comp2-info-header">
|
||||
<span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
|
||||
</div>
|
||||
<div className="comp2-info-content">
|
||||
<div className="comp2-file-info">
|
||||
<h3 className="comp2-filename">{getFileName()}</h3>
|
||||
<p className="comp2-filesize">19.6MB</p>
|
||||
</div>
|
||||
<div className="comp2-meta-grid">
|
||||
<div className="comp2-meta-item">
|
||||
<span className="comp2-meta-label">{t('completion.genre', { defaultValue: '장르' })} : {songCompletionData?.genre || 'K-POP'}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{youtubeError && (
|
||||
<p className="text-red-400 text-sm mt-2">{youtubeError}</p>
|
||||
)}
|
||||
<div className="comp2-meta-divider"></div>
|
||||
<div className="comp2-meta-item">
|
||||
<span className="comp2-meta-label">{t('completion.resolution', { defaultValue: '규격' })} : {getVideoResolution()}</span>
|
||||
</div>
|
||||
<div className="comp2-meta-divider"></div>
|
||||
<div className="comp2-lyrics-section">
|
||||
<span className="comp2-meta-label">{t('completion.lyrics', { defaultValue: '가사' })}</span>
|
||||
<p className="comp2-lyrics-text">
|
||||
{songCompletionData?.lyrics || t('completion.sampleLyrics', { defaultValue: '가사를 불러오는 중...' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 하단 버튼 */}
|
||||
<div className="comp2-buttons">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||
className="comp2-btn comp2-btn-secondary"
|
||||
>
|
||||
{t('completion.download', { defaultValue: '다운로드' })}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenSocialConnect}
|
||||
disabled={videoStatus !== 'complete'}
|
||||
className="comp2-btn comp2-btn-primary"
|
||||
>
|
||||
{t('completion.uploadToSocial', { defaultValue: '소셜 채널 업로드' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sharing-actions">
|
||||
<button
|
||||
onClick={handleDeploy}
|
||||
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
|
||||
className="btn-completion-deploy"
|
||||
>
|
||||
{t('completion.deployToSocial')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={videoStatus !== 'complete' || !videoUrl}
|
||||
className="btn-completion-download"
|
||||
>
|
||||
{t('completion.downloadMp4')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
{/* 소셜 미디어 포스팅 모달 (기존 SocialPostingModal 컴포넌트 사용) */}
|
||||
<SocialPostingModal
|
||||
isOpen={showSocialModal}
|
||||
onClose={handleCloseSocialConnect}
|
||||
video={videoUrl ? {
|
||||
video_id: 0,
|
||||
store_name: songCompletionData?.businessName || '',
|
||||
region: '',
|
||||
task_id: songTaskId || '',
|
||||
result_movie_url: videoUrl,
|
||||
created_at: new Date().toISOString(),
|
||||
} : null}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompletionContent;
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
|
||||
// analysisData 변경 시 imageList 업데이트
|
||||
useEffect(() => {
|
||||
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
|
||||
if (analysisData?.image_list && analysisData.image_list.length > 0) {
|
||||
setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
|
||||
}
|
||||
|
|
@ -195,6 +196,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
|
||||
try {
|
||||
const data = await autocomplete(request);
|
||||
console.log('[Autocomplete] Response m_id:', data.m_id);
|
||||
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
|
|
@ -219,9 +221,14 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
|
||||
const generateRandomMId = (): number => {
|
||||
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
|
||||
};
|
||||
|
||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
||||
const handleTestData = (data: CrawlingResponse) => {
|
||||
const tagged = { ...data, _isTestData: true };
|
||||
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
goToWizardStep(0); // 브랜드 분석 결과로
|
||||
|
|
@ -238,7 +245,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
fetch(jsonFile)
|
||||
.then(res => res.json())
|
||||
.then((data: CrawlingResponse) => {
|
||||
const tagged = { ...data, _isTestData: true };
|
||||
// 언어 변경 시에는 기존 m_id 유지
|
||||
const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
|
||||
setAnalysisData(tagged);
|
||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
||||
})
|
||||
|
|
@ -255,6 +263,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
|||
|
||||
try {
|
||||
const data = await crawlUrl(url);
|
||||
console.log('[Crawl] Response m_id:', data.m_id);
|
||||
|
||||
// 기본값 보장
|
||||
if (data.marketing_analysis) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const languageDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 완료 데이터를 localStorage에 저장
|
||||
const saveSongCompletionData = () => {
|
||||
const completionData = {
|
||||
businessName: businessInfo?.customer_name || '알 수 없음',
|
||||
genre: selectedGenre === '자동 선택' ? 'K-POP' : selectedGenre,
|
||||
lyrics: lyrics,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
localStorage.setItem('castad_song_completion', JSON.stringify(completionData));
|
||||
};
|
||||
|
||||
// Close language dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -84,6 +95,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
// Auto-navigate to next page when video generation is complete
|
||||
useEffect(() => {
|
||||
if (videoGenerationStatus === 'complete' && songTaskId) {
|
||||
// Save completion data before navigating
|
||||
saveSongCompletionData();
|
||||
// Wait a brief moment to show 100% completion before navigating
|
||||
const timer = setTimeout(() => {
|
||||
onNext(songTaskId);
|
||||
|
|
@ -277,6 +290,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
const language = LANGUAGE_MAP[selectedLang] || 'Korean';
|
||||
|
||||
// 1. 가사 생성 요청
|
||||
console.log('[SoundStudio] Sending m_id:', mId);
|
||||
const lyricResponse = await generateLyric({
|
||||
customer_name: businessInfo.customer_name,
|
||||
detail_region_info: businessInfo.detail_region_info,
|
||||
|
|
@ -404,20 +418,16 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
{[
|
||||
{ key: '보컬', label: t('soundStudio.soundTypeVocal') },
|
||||
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') },
|
||||
{ key: '성우 내레이션', label: t('soundStudio.soundTypeNarration') },
|
||||
].map(({ key, label }) => {
|
||||
const isDisabled = key === '성우 내레이션' || isGenerating;
|
||||
return (
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => !isDisabled && setSelectedType(key)}
|
||||
disabled={isDisabled}
|
||||
className={`sound-type-btn ${selectedType === key ? 'active' : ''} ${key === '성우 내레이션' ? 'permanently-disabled' : ''}`}
|
||||
onClick={() => !isGenerating && setSelectedType(key)}
|
||||
disabled={isGenerating}
|
||||
className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -608,7 +618,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
|
|||
{/* Bottom Button */}
|
||||
<div className="bottom-button-container">
|
||||
<button
|
||||
onClick={() => songTaskId && onNext(songTaskId)}
|
||||
onClick={() => {
|
||||
if (songTaskId) {
|
||||
saveSongCompletionData();
|
||||
onNext(songTaskId);
|
||||
}
|
||||
}}
|
||||
disabled={status !== 'complete' || !songTaskId || videoGenerationStatus === 'generating'}
|
||||
className={`btn-video-generate ${
|
||||
videoGenerationStatus === 'generating'
|
||||
|
|
|
|||
Loading…
Reference in New Issue