브랜드 분석, 콘텐츠 완료 페이지 수정 .

main
hbyang 2026-02-13 17:02:01 +09:00
parent b89cf027af
commit e448d8189d
12 changed files with 1456 additions and 689 deletions

999
index.css

File diff suppressed because it is too large Load Diff

View File

@ -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 = {

View File

@ -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": "",

View File

@ -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": "",

11
public/favicon_32.svg Normal file
View File

@ -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

11
public/favicon_48.svg Normal file
View File

@ -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

View File

@ -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",

View File

@ -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": "발라드",

View File

@ -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}`}>
<g transform={`translate(${pos.x}, ${pos.y})`}>
<rect
x="-10"
y="-10"
width="20"
height="20"
rx="4"
fill={isTopThree ? '#94FBE0' : '#206764'}
/>
<text
x={pos.x}
y={pos.y}
dy={dy}
textAnchor={textAnchor}
className="bi-chart-label"
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>
<text
x={pos.x}
y={pos.y}
dy={dy + 16}
textAnchor={textAnchor}
className="bi-chart-sublabel"
>
{item.english_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>
<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')}
<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>
{/* 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>
<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>
{/* 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="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 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="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 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>
</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 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>
</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>
</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">
{/* 레이더 차트 */}
<div className="bi2-selling-chart">
{sellingPoints.length > 0 && (
<div className="mb-8">
<RadarChart data={sellingPoints} size={280} />
<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>
)}
{/* 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-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>
</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">
<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>

View File

@ -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,26 +359,24 @@ 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">
<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>
@ -488,195 +384,146 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</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">
<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 ? (
/* Loading State */
<div className="video-loading">
<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="text-gray-400 mt-4">{statusMessage}</p>
<p className="comp2-loading-text">{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">
<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="text-gray-400 mb-4">{errorMessage}</p>
<button onClick={handleRetry} className="btn-secondary">
<p className="comp2-error-text">{errorMessage}</p>
<button onClick={handleRetry} className="comp2-retry-btn">
{t('completion.retry')}
</button>
</div>
) : videoUrl ? (
<>
{/* Video Player */}
<video
ref={videoRef}
src={videoUrl}
className="video-player"
className="comp2-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}>
<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 className="w-7 h-7" 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="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
) : (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z"/>
</svg>
)}
</button>
<div className="video-progress">
<div className="video-progress-fill" style={{ width: `${progress}%` }}></div>
<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>
{/* 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>
</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>
);
})}
{youtubeError && (
<p className="text-red-400 text-sm mt-2">{youtubeError}</p>
<div className="comp2-video-placeholder"></div>
)}
</div>
</div>
<div className="sharing-actions">
<button
onClick={handleDeploy}
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'}
className="btn-completion-deploy"
>
{t('completion.deployToSocial')}
</button>
{/* 오른쪽: 콘텐츠 정보 */}
<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>
<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="btn-completion-download"
className="comp2-btn comp2-btn-secondary"
>
{t('completion.downloadMp4')}
{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>
{/* 소셜 미디어 포스팅 모달 (기존 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>
);
};

View File

@ -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) {

View File

@ -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'