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

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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>CASTAD</title> <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 src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {

View File

@ -122,6 +122,7 @@
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg" "https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
], ],
"image_count": 119, "image_count": 119,
"m_id": 1,
"processed_info": { "processed_info": {
"customer_name": "풀스테이 스테이펫 홍천", "customer_name": "풀스테이 스테이펫 홍천",
"region": "", "region": "",

View File

@ -122,6 +122,7 @@
"https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg" "https://ldb-phinf.pstatic.net/20240603_220/1717383528017rKdOw_JPEG/6S5A6773.jpg"
], ],
"image_count": 119, "image_count": 119,
"m_id": 1,
"processed_info": { "processed_info": {
"customer_name": "Fullstay StayPet Hongcheon", "customer_name": "Fullstay StayPet Hongcheon",
"region": "", "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", "publishTimeLabel": "Publish Time",
"publishNow": "Publish Now", "publishNow": "Publish Now",
"publishSchedule": "Schedule (Coming Soon)", "publishSchedule": "Schedule (Coming Soon)",
"footerNote": "Posting is available after {link}.", "footerNote": "",
"footerNoteLink": "plan upgrade", "footerNoteLink": "",
"cancel": "Cancel", "cancel": "Cancel",
"posting": "Posting...", "posting": "Posting...",
"post": "Post", "post": "Post",
@ -135,7 +135,6 @@
"soundTypeLabel": "Select AI Sound Type", "soundTypeLabel": "Select AI Sound Type",
"soundTypeVocal": "Vocal", "soundTypeVocal": "Vocal",
"soundTypeBGM": "Background Music", "soundTypeBGM": "Background Music",
"soundTypeNarration": "Voice Narration",
"genreLabel": "Select Genre", "genreLabel": "Select Genre",
"genreAuto": "Auto Select", "genreAuto": "Auto Select",
"genreBallad": "Ballad", "genreBallad": "Ballad",

View File

@ -43,8 +43,8 @@
"publishTimeLabel": "게시 시간", "publishTimeLabel": "게시 시간",
"publishNow": "지금 게시", "publishNow": "지금 게시",
"publishSchedule": "시간 설정 (준비 중)", "publishSchedule": "시간 설정 (준비 중)",
"footerNote": "게시는 {link} 후 가능합니다.", "footerNote": "",
"footerNoteLink": "요금제 업그레이드", "footerNoteLink": "",
"cancel": "취소", "cancel": "취소",
"posting": "게시 중...", "posting": "게시 중...",
"post": "게시", "post": "게시",
@ -135,7 +135,6 @@
"soundTypeLabel": "AI 사운드 유형 선택", "soundTypeLabel": "AI 사운드 유형 선택",
"soundTypeVocal": "보컬", "soundTypeVocal": "보컬",
"soundTypeBGM": "배경음악", "soundTypeBGM": "배경음악",
"soundTypeNarration": "성우 내레이션",
"genreLabel": "장르 선택", "genreLabel": "장르 선택",
"genreAuto": "자동 선택", "genreAuto": "자동 선택",
"genreBallad": "발라드", "genreBallad": "발라드",

View File

@ -9,105 +9,13 @@ interface AnalysisResultSectionProps {
data: CrawlingResponse; 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 { interface RadarChartProps {
data: SellingPoint[]; data: SellingPoint[];
size?: number; 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 [animatedScores, setAnimatedScores] = useState<number[]>(data.map(() => 0));
const [isAnimating, setIsAnimating] = useState(true); const [isAnimating, setIsAnimating] = useState(true);
@ -134,7 +42,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
requestAnimationFrame(animate); requestAnimationFrame(animate);
}, [data]); }, [data]);
const padding = 60; const padding = 80;
const center = size / 2 + padding; const center = size / 2 + padding;
const maxRadius = size / 2 - 20; const maxRadius = size / 2 - 20;
const levels = 5; 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 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'; 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 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 ( return (
<div className="relative flex justify-center items-center" style={{ overflow: 'visible' }}> <div className="bi2-radar-container">
<svg <svg
width={svgSize} width={svgSize}
height={svgSize} height={svgSize}
@ -195,7 +103,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
key={`level-${i}`} key={`level-${i}`}
d={path} d={path}
fill="none" fill="none"
stroke="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)"
strokeWidth="1" strokeWidth="1"
/> />
))} ))}
@ -205,7 +113,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
key={`axis-${i}`} key={`axis-${i}`}
d={path} d={path}
fill="none" fill="none"
stroke="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)"
strokeWidth="1" strokeWidth="1"
/> />
))} ))}
@ -213,7 +121,7 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
<path <path
d={dataPath} d={dataPath}
fill="rgba(45, 212, 191, 0.2)" fill="rgba(45, 212, 191, 0.2)"
stroke="#2dd4bf" stroke="#2DD4BF"
strokeWidth="2" strokeWidth="2"
style={{ style={{
transition: isAnimating ? 'none' : 'd 0.3s ease-out', transition: isAnimating ? 'none' : 'd 0.3s ease-out',
@ -226,48 +134,62 @@ const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
cx={point.x} cx={point.x}
cy={point.y} cy={point.y}
r="4" r="4"
fill="#2dd4bf" fill="#2DD4BF"
stroke="#1a1a2e" stroke="#1A1A2E"
strokeWidth="2" strokeWidth="2"
/> />
))} ))}
{data.map((item, i) => { {data.map((item, i) => {
const pos = getLabelPosition(i); const angle = angleStep * i - Math.PI / 2;
const isLeft = pos.x < center - 10; const labelRadius = maxRadius + 40;
const isRight = pos.x > center + 10; const pos = {
const isTop = pos.y < center - 10; x: center + labelRadius * Math.cos(angle),
const isBottom = pos.y > center + 10; y: center + labelRadius * Math.sin(angle),
};
let textAnchor: 'start' | 'middle' | 'end' = 'middle'; const rank = rankMap.get(i) || i + 1;
let dy = 0; const isTopThree = rank <= 3;
if (isLeft) textAnchor = 'end';
else if (isRight) textAnchor = 'start';
if (isTop && !isLeft && !isRight) dy = -8;
else if (isBottom && !isLeft && !isRight) dy = 5;
return ( return (
<g key={`label-${i}`}> <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 <text
x={pos.x} x="0"
y={pos.y} y="0"
dy={dy} textAnchor="middle"
textAnchor={textAnchor} dominantBaseline="central"
className="bi-chart-label" 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} {item.korean_category}
</text> </text>
<text </g>
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 sellingPoints = marketing_analysis?.selling_points || [];
const targetKeywords = marketing_analysis?.target_keywords || []; const targetKeywords = marketing_analysis?.target_keywords || [];
// 셀링 포인트를 score 내림차순으로 정렬
const sortedSellingPoints = [...sellingPoints].sort((a, b) => b.score - a.score);
useEffect(() => { useEffect(() => {
const updateButtonPosition = () => { const updateButtonPosition = () => {
if (containerRef.current) { 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 ( 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 */} {/* Header */}
<div className="asset-header"> <div className="bi2-header">
<button onClick={onBack} className="btn-back-new"> <button onClick={onBack} className="bi2-back-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
@ -324,158 +245,124 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</button> </button>
</div> </div>
{/* Page Header */} {/* Page Title */}
<div className="bi-page-header"> <div className="bi2-page-title-section">
<div className="bi-page-icon"> <div className="bi2-title-icon">
<img src="/assets/images/star-icon.svg" alt="Star" className="bi-star-icon" /> <img src="/assets/images/star-icon.svg" alt="" className="bi2-star-icon" />
</div> </div>
<h1 className="bi-page-title">{t('analysis.pageTitle')}</h1> <div className="bi2-title-text">
<p className="bi-page-desc"> <h1 className="bi2-main-title">{t('analysis.pageTitle')}</h1>
<span className="highlight">{t('analysis.pageDescHighlight')}</span>{t('analysis.pageDescBefore')}{processed_info?.customer_name || t('analysis.defaultBrandName')}{t('analysis.pageDescAfter')} <p className="bi2-subtitle">
{t('analysis.pageDescHighlight')}{t('analysis.pageDescBefore')}{processed_info?.customer_name || t('analysis.defaultBrandName')}{t('analysis.pageDescAfter')}
</p> </p>
</div> </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> </div>
<h2 className="bi-brand-name">{processed_info?.customer_name || t('analysis.brandNameFallback')}</h2> {/* Main Content Container */}
<div ref={containerRef} className="bi2-main-container">
<div className="bi-location mb-6"> {/* 매장명 & 주소 */}
<MapPinIcon /> <div className="bi2-store-header">
<div> <h2 className="bi2-store-name">{processed_info?.customer_name || t('analysis.brandNameFallback')}</h2>
<p>{processed_info?.detail_region_info || t('analysis.addressFallback')}</p> <p className="bi2-store-address">{processed_info?.detail_region_info || t('analysis.addressFallback')}</p>
<p style={{ opacity: 0.7, marginTop: '4px' }}>{processed_info?.region || ''}</p>
</div>
</div> </div>
<div className="space-y-6 border-t border-white/5 pt-6"> {/* 브랜드 정체성 */}
<AnimatedItem index={0} baseDelay={100}> <div className="bi2-section">
<div className="bi-subsection-title">{t('analysis.locationAnalysis')}</div> <h3 className="bi2-section-title">{t('analysis.brandIdentity')}</h3>
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || t('analysis.noInfo')}</p> <div className="bi2-identity-card">
</AnimatedItem> <div className="bi2-identity-top">
<AnimatedItem index={1} baseDelay={100}> <div className="bi2-identity-core">
<div className="bi-subsection-title">{t('analysis.conceptScalability')}</div> <span className="bi2-label-sm">{t('analysis.coreValue')}</span>
<p className="bi-body-text">{brandIdentity?.concept_scalability || t('analysis.noInfo')}</p> <p className="bi2-core-value">{marketPositioning?.core_value || t('analysis.noInfo')}</p>
</AnimatedItem>
</div> </div>
<p className="bi2-category-text">{t('analysis.categoryDefinition')} : {marketPositioning?.category_definition || t('analysis.noInfo')}</p>
</div> </div>
<div className="bi2-identity-divider"></div>
{/* 시장 포지셔닝 카드 */} <div className="bi2-identity-bottom">
<div className="bi-card"> <div className="bi2-identity-col">
<h3 className="bi-card-title"> <span className="bi2-label-sm">{t('analysis.locationAnalysis')}</span>
{t('analysis.marketPositioning')} <p className="bi2-body-text">{brandIdentity?.location_feature_analysis || t('analysis.noInfo')}</p>
</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> </div>
</AnimatedItem> <div className="bi2-identity-col">
<AnimatedItem index={1} baseDelay={200}> <span className="bi2-label-sm">{t('analysis.conceptScalability')}</span>
<div className="bi-inner-box"> <p className="bi2-body-text">{brandIdentity?.concept_scalability || t('analysis.noInfo')}</p>
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>{t('analysis.categoryDefinition')}</div>
<div className="bi-value">{marketPositioning?.category_definition || t('analysis.noInfo')}</div>
</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> </div>
</div> </div>
</div> </div>
{/* 오른쪽 컬럼 */} {/* 주요 셀링 포인트 */}
<div className="space-y-6"> <div className="bi2-section">
{/* 주요 셀링 포인트 카드 */} <h3 className="bi2-section-title">{t('analysis.sellingPoints')}</h3>
<div className="bi-card min-h-[500px] flex flex-col"> <div className="bi2-selling-card">
<h3 className="bi-card-title mb-6">
{t('analysis.sellingPoints')}
</h3>
{/* 레이더 차트 */} {/* 레이더 차트 */}
<div className="bi2-selling-chart">
{sellingPoints.length > 0 && ( {sellingPoints.length > 0 && (
<div className="mb-8"> <RadarChart data={sellingPoints} size={360} />
<RadarChart data={sellingPoints} size={280} /> )}
</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>
)}
{/* Top USP 하이라이트 */} {/* 주요 고객 유형 */}
{topUSP && ( <div className="bi2-section">
<AnimatedUSPItem <h3 className="bi2-section-title">{t('analysis.targetPersona')}</h3>
usp={topUSP} <div className="bi2-persona-grid">
index={0} {targetPersonas.map((persona: TargetPersona, idx: number) => (
isTop={true} <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>
{/* 나머지 USP 리스트 */} <span className="bi2-persona-age">{persona.age.min_age}~{persona.age.max_age}{t('analysis.ageSuffix')}</span>
<div className="space-y-4 flex-1"> </div>
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</p>} <p className="bi2-persona-desc">
{sellingPoints {persona.decision_trigger}
.filter((usp: SellingPoint) => usp.english_category !== topUSP?.english_category) </p>
.map((usp: SellingPoint, idx: number) => ( </div>
<AnimatedUSPItem <div className="bi2-persona-divider"></div>
key={idx} <div className="bi2-persona-detail grow">
usp={usp} <span className="bi2-label-xs">{t('analysis.favorKeywords', { defaultValue: '선호 키워드' })}</span>
index={idx + 1} <p className="bi2-persona-detail-text">
isTop={false} {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>
</div>
{/* 추천 타겟 키워드 */} {/* 추천 타겟 키워드 */}
<div className="max-w-[1440px] mx-auto px-4 md:px-8 mt-10"> <div className="bi2-section">
<div className="bi-card"> <div className="bi2-keyword-header">
<h3 className="bi-card-title">{t('analysis.recommendedKeywords')}</h3> <h3 className="bi2-section-title">{t('analysis.recommendedKeywords')}</h3>
<div className="flex flex-wrap gap-3"> <p className="bi2-keyword-subtitle">{t('analysis.keywordHint', { defaultValue: '이런 키워드로 찾을 가능성이 높아요' })}</p>
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>{t('analysis.noInfo')}</span>} </div>
<div className="bi2-keyword-tags">
{targetKeywords.map((keyword: string, idx: number) => ( {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>
</div> </div>
@ -490,7 +377,9 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
onClick={onGenerate} 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" 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')} {t('analysis.generateContent')}
</button> </button>
</div> </div>

View File

@ -1,8 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { generateVideo, waitForVideoComplete, getYouTubeConnectUrl, getSocialAccounts, disconnectSocialAccount, TokenExpiredError, handleSocialReconnect } from '../../utils/api'; import { generateVideo, waitForVideoComplete } from '../../utils/api';
import { SocialAccount } from '../../types/api'; import SocialPostingModal from '../../components/SocialPostingModal';
interface CompletionContentProps { interface CompletionContentProps {
onBack: () => void; onBack: () => void;
@ -12,10 +12,9 @@ interface CompletionContentProps {
} }
type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error'; type VideoStatus = 'idle' | 'generating' | 'polling' | 'complete' | 'error';
const VIDEO_STORAGE_KEY = 'castad_video_generation'; const VIDEO_STORAGE_KEY = 'castad_video_generation';
const VIDEO_COMPLETE_KEY = 'castad_video_complete'; // 완료된 영상 정보 (만료 없음) const VIDEO_COMPLETE_KEY = 'castad_video_complete';
const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000; // 30분 const VIDEO_STORAGE_EXPIRY = 30 * 60 * 1000;
interface SavedVideoState { interface SavedVideoState {
videoTaskId: string; videoTaskId: string;
@ -32,23 +31,31 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
onVideoProgressChange onVideoProgressChange
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedSocials, setSelectedSocials] = useState<string[]>([]);
const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle'); const [videoStatus, setVideoStatus] = useState<VideoStatus>('idle');
const [videoUrl, setVideoUrl] = useState<string | null>(null); const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [renderProgress, setRenderProgress] = useState(0); // 영상 렌더링 진행률 (0-100) const [renderProgress, setRenderProgress] = useState(0);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [showControls, setShowControls] = useState(true);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const hasStartedGeneration = useRef(false); const hasStartedGeneration = useRef(false);
const hideControlsTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// YouTube 연결 상태 // 소셜 미디어 포스팅 모달
const [youtubeAccount, setYoutubeAccount] = useState<SocialAccount | null>(null); const [showSocialModal, setShowSocialModal] = useState(false);
const [isYoutubeConnecting, setIsYoutubeConnecting] = useState(false);
const [youtubeError, setYoutubeError] = useState<string | null>(null); // 저장된 완료 데이터
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(() => { useEffect(() => {
if (onVideoStatusChange) { if (onVideoStatusChange) {
const mappedStatus = videoStatus === 'polling' ? 'generating' : videoStatus; const mappedStatus = videoStatus === 'polling' ? 'generating' : videoStatus;
@ -56,7 +63,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}, [videoStatus, onVideoStatusChange]); }, [videoStatus, onVideoStatusChange]);
// Notify parent of progress changes
useEffect(() => { useEffect(() => {
if (onVideoProgressChange) { if (onVideoProgressChange) {
onVideoProgressChange(renderProgress); onVideoProgressChange(renderProgress);
@ -73,7 +79,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
}; };
localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data)); localStorage.setItem(VIDEO_STORAGE_KEY, JSON.stringify(data));
// 완료된 경우 별도의 만료 없는 키에도 저장
if (status === 'complete' && url) { if (status === 'complete' && url) {
const completeData = { const completeData = {
songTaskId: currentSongTaskId, songTaskId: currentSongTaskId,
@ -81,16 +86,13 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
completedAt: Date.now(), completedAt: Date.now(),
}; };
localStorage.setItem(VIDEO_COMPLETE_KEY, JSON.stringify(completeData)); localStorage.setItem(VIDEO_COMPLETE_KEY, JSON.stringify(completeData));
console.log('[Video] Saved complete state:', completeData);
} }
}; };
const clearStorage = () => { const clearStorage = () => {
localStorage.removeItem(VIDEO_STORAGE_KEY); localStorage.removeItem(VIDEO_STORAGE_KEY);
// 주의: VIDEO_COMPLETE_KEY는 여기서 삭제하지 않음 (명시적으로만 삭제)
}; };
// 완료된 영상 정보 로드 (만료 없음)
const loadCompleteVideo = (): { songTaskId: string; videoUrl: string } | null => { const loadCompleteVideo = (): { songTaskId: string; videoUrl: string } | null => {
try { try {
const saved = localStorage.getItem(VIDEO_COMPLETE_KEY); const saved = localStorage.getItem(VIDEO_COMPLETE_KEY);
@ -129,7 +131,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setErrorMessage(null); setErrorMessage(null);
try { try {
// Get video ratio from localStorage (default to 'vertical' if not set)
const savedRatio = localStorage.getItem('castad_video_ratio'); const savedRatio = localStorage.getItem('castad_video_ratio');
const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical'; const orientation = (savedRatio === 'horizontal' || savedRatio === 'vertical') ? savedRatio : 'vertical';
@ -141,7 +142,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
setVideoStatus('polling'); setVideoStatus('polling');
setStatusMessage(t('completion.generatingVideo')); setStatusMessage(t('completion.generatingVideo'));
// video/status API는 creatomate_render_id를 사용
saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null); saveToStorage(videoResponse.creatomate_render_id, songTaskId, 'polling', null);
await pollVideoStatus(videoResponse.creatomate_render_id, songTaskId); await pollVideoStatus(videoResponse.creatomate_render_id, songTaskId);
@ -155,7 +155,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}; };
// 상태별 한글 메시지 및 진행률 매핑
const getStatusMessage = (status: string): string => { const getStatusMessage = (status: string): string => {
switch (status) { switch (status) {
case 'planned': case 'planned':
@ -173,7 +172,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}; };
// 상태별 진행률 계산
const getProgressForStatus = (status: string): number => { const getProgressForStatus = (status: string): number => {
switch (status) { switch (status) {
case 'planned': case 'planned':
@ -193,7 +191,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => { const pollVideoStatus = async (videoTaskId: string, currentSongTaskId: string) => {
try { try {
// 영상 생성 상태 폴링 (3분 타임아웃, 3초 간격)
const statusResponse = await waitForVideoComplete( const statusResponse = await waitForVideoComplete(
videoTaskId, videoTaskId,
(status: string) => { (status: string) => {
@ -202,7 +199,6 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
); );
// render_data.url에서 영상 URL 가져오기
const videoUrlFromResponse = statusResponse.render_data?.url; const videoUrlFromResponse = statusResponse.render_data?.url;
if (videoUrlFromResponse) { if (videoUrlFromResponse) {
@ -230,178 +226,65 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}; };
// 컴포넌트 마운트 시 저장된 상태 확인 또는 영상 생성 시작
useEffect(() => { useEffect(() => {
if (!songTaskId) return; if (!songTaskId) return;
// 1. 먼저 완료된 영상 정보 확인 (만료 없음)
const completeVideo = loadCompleteVideo(); const completeVideo = loadCompleteVideo();
if (completeVideo && completeVideo.songTaskId === songTaskId && completeVideo.videoUrl) { if (completeVideo && completeVideo.songTaskId === songTaskId && completeVideo.videoUrl) {
console.log('[Video] Restored from complete storage:', completeVideo);
setVideoUrl(completeVideo.videoUrl); setVideoUrl(completeVideo.videoUrl);
setVideoStatus('complete'); setVideoStatus('complete');
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
return; // 완료된 영상이 있으면 더 이상 진행하지 않음 return;
} }
// 2. 일반 저장소에서 상태 확인 (30분 만료)
const savedState = loadFromStorage(); const savedState = loadFromStorage();
// 저장된 상태가 있고, 같은 songTaskId인 경우
if (savedState && savedState.songTaskId === songTaskId) { if (savedState && savedState.songTaskId === songTaskId) {
if (savedState.status === 'complete' && savedState.videoUrl) { if (savedState.status === 'complete' && savedState.videoUrl) {
// 이미 완료된 경우
setVideoUrl(savedState.videoUrl); setVideoUrl(savedState.videoUrl);
setVideoStatus('complete'); setVideoStatus('complete');
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
} else if (savedState.status === 'polling') { } else if (savedState.status === 'polling') {
// 폴링 중이었던 경우 다시 폴링
setVideoStatus('polling'); setVideoStatus('polling');
setStatusMessage(t('completion.processingAfterRefresh')); setStatusMessage(t('completion.processingAfterRefresh'));
hasStartedGeneration.current = true; hasStartedGeneration.current = true;
pollVideoStatus(savedState.videoTaskId, savedState.songTaskId); pollVideoStatus(savedState.videoTaskId, savedState.songTaskId);
} }
} else if (!hasStartedGeneration.current) { } else if (!hasStartedGeneration.current) {
// 새로운 영상 생성 시작
startVideoGeneration(); startVideoGeneration();
} }
}, [songTaskId]); }, [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(() => { useEffect(() => {
// 먼저 localStorage에서 방금 연결된 계정 확인 try {
const foundInLocalStorage = checkLocalStorageForConnectedAccount(); 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) { const savedRatio = localStorage.getItem('castad_video_ratio');
checkYoutubeConnection(); if (savedRatio === 'horizontal' || savedRatio === 'vertical') {
} else { setVideoRatio(savedRatio);
// localStorage에서 찾았어도 API로 최신 정보 갱신 (백그라운드)
checkYoutubeConnection();
} }
}, []); }, []);
// 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 = () => { const togglePlayPause = () => {
if (!videoRef.current || !videoUrl) return; if (!videoRef.current || !videoUrl) return;
if (isPlaying) { if (isPlaying) {
videoRef.current.pause(); videoRef.current.pause();
setShowControls(true);
if (hideControlsTimer.current) clearTimeout(hideControlsTimer.current);
} else { } else {
videoRef.current.play(); videoRef.current.play();
hideControlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 3000);
} }
setIsPlaying(!isPlaying); setIsPlaying(!isPlaying);
}; };
@ -415,6 +298,36 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
const handleVideoEnded = () => { const handleVideoEnded = () => {
setIsPlaying(false); setIsPlaying(false);
setProgress(0); 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 = () => { const handleDownload = () => {
@ -429,27 +342,12 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
} }
}; };
// 소셜 채널에 배포 const handleOpenSocialConnect = () => {
const handleDeploy = () => { setShowSocialModal(true);
if (selectedSocials.length === 0) { };
alert(t('completion.selectSocialChannel'));
return;
}
if (!videoUrl) { const handleCloseSocialConnect = () => {
alert(t('completion.videoNotReady')); setShowSocialModal(false);
return;
}
// 선택된 채널 중 YouTube가 있고 연결된 경우
if (selectedSocials.includes('Youtube') && youtubeAccount) {
// TODO: YouTube 업로드 API 호출
alert(t('completion.youtubeUploadMessage', { channelName: youtubeAccount.display_name }));
return;
}
// 다른 플랫폼 선택 시
alert(t('completion.deployComingSoon'));
}; };
const handleRetry = () => { const handleRetry = () => {
@ -461,26 +359,24 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
startVideoGeneration(); 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 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 ( return (
<main className="page-container"> <main className="comp2-page">
{/* Header with Back Button */} <div className="comp2-header">
<div className="asset-header"> <button onClick={onBack} className="comp2-back-btn">
<button onClick={onBack} className="btn-back-new">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" /> <path d="M15 18l-6-6 6-6" />
</svg> </svg>
@ -488,195 +384,146 @@ const CompletionContent: React.FC<CompletionContentProps> = ({
</button> </button>
</div> </div>
{/* Title */} <div className="comp2-title-row">
<h1 className="completion-title"> <h1 className="comp2-page-title">{t('completion.contentComplete', { defaultValue: '콘텐츠 제작 완료' })}</h1>
{isLoading ? t('completion.titleGenerating') : videoStatus === 'error' ? t('completion.titleError') : t('completion.titleComplete')} <p className="comp2-page-subtitle">{t('completion.contentCompleteDesc', { defaultValue: 'AI 분석 및 편집을 통해 최적화된 콘텐츠가 완성되었습니다' })}</p>
</h1> </div>
{/* Main Content Container */} <div className="comp2-container">
<div className="completion-container"> <div className="comp2-grid">
{/* Left: Video Preview */} {/* 왼쪽: 영상 */}
<div className="completion-column completion-column-left video-preview-card"> <div className="comp2-video-section">
<h3 className="asset-section-title">{t('completion.imageAndVideo')}</h3> <div className="comp2-video-wrapper" onMouseEnter={handleVideoMouseEnter} onMouseMove={handleVideoMouseMove} onMouseLeave={handleVideoMouseLeave}>
<div className="completion-video-wrapper">
<div className="video-container">
{isLoading ? ( {isLoading ? (
/* Loading State */ <div className="comp2-video-loading">
<div className="video-loading">
<div className="loading-spinner"> <div className="loading-spinner">
<div className="loading-ring"></div> <div className="loading-ring"></div>
<div className="loading-dot"> <div className="loading-dot">
<div className="loading-dot-inner"></div> <div className="loading-dot-inner"></div>
</div> </div>
</div> </div>
<p className="text-gray-400 mt-4">{statusMessage}</p> <p className="comp2-loading-text">{statusMessage}</p>
</div> </div>
) : videoStatus === 'error' ? ( ) : videoStatus === 'error' ? (
/* Error State */ <div className="comp2-video-error">
<div className="video-error"> <svg className="comp2-error-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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" /> <circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" /> <path d="M12 8v4M12 16h.01" />
</svg> </svg>
<p className="text-gray-400 mb-4">{errorMessage}</p> <p className="comp2-error-text">{errorMessage}</p>
<button onClick={handleRetry} className="btn-secondary"> <button onClick={handleRetry} className="comp2-retry-btn">
{t('completion.retry')} {t('completion.retry')}
</button> </button>
</div> </div>
) : videoUrl ? ( ) : videoUrl ? (
<> <>
{/* Video Player */}
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}
className="video-player" className="comp2-video-player"
onTimeUpdate={handleTimeUpdate} onTimeUpdate={handleTimeUpdate}
onEnded={handleVideoEnded} onEnded={handleVideoEnded}
onClick={togglePlayPause} onClick={togglePlayPause}
/> />
</> <div className={`comp2-video-controls ${showControls ? 'visible' : 'hidden'}`}>
) : ( <div className="comp2-progress-bar">
<div className="video-pattern"></div> <div className="comp2-progress-fill" style={{ width: `${progress}%` }}></div>
)} </div>
<div className="comp2-controls-row">
{/* Video Player Controls - only show when video is ready */} <button className="comp2-play-btn" onClick={togglePlayPause}>
{videoStatus === 'complete' && videoUrl && (
<div className="video-controls">
<div className="video-controls-inner">
<button className="video-play-btn" onClick={togglePlayPause}>
{isPlaying ? ( {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> </button>
<div className="video-progress"> <div className="comp2-volume-control">
<div className="video-progress-fill" style={{ width: `${progress}%` }}></div> <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>
</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 className="comp2-video-placeholder"></div>
)}
</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> </div>
</div> </div>
<div className="sharing-actions"> {/* 오른쪽: 콘텐츠 정보 */}
<button <div className="comp2-info-section">
onClick={handleDeploy} <div className="comp2-info-header">
disabled={selectedSocials.length === 0 || videoStatus !== 'complete'} <span className="comp2-info-label">{t('completion.contentInfo', { defaultValue: '콘텐츠 정보' })}</span>
className="btn-completion-deploy" </div>
> <div className="comp2-info-content">
{t('completion.deployToSocial')} <div className="comp2-file-info">
</button> <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 <button
onClick={handleDownload} onClick={handleDownload}
disabled={videoStatus !== 'complete' || !videoUrl} 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> </button>
</div> </div>
</div> </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> </main>
); );
}; };

View File

@ -144,6 +144,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
// analysisData 변경 시 imageList 업데이트 // analysisData 변경 시 imageList 업데이트
useEffect(() => { useEffect(() => {
console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id);
if (analysisData?.image_list && analysisData.image_list.length > 0) { if (analysisData?.image_list && analysisData.image_list.length > 0) {
setImageList(analysisData.image_list.map(url => ({ type: 'url', url }))); setImageList(analysisData.image_list.map(url => ({ type: 'url', url })));
} }
@ -195,6 +196,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
try { try {
const data = await autocomplete(request); const data = await autocomplete(request);
console.log('[Autocomplete] Response m_id:', data.m_id);
// 기본값 보장 // 기본값 보장
if (data.marketing_analysis) { 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 handleTestData = (data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true }; const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
setAnalysisData(tagged); setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
goToWizardStep(0); // 브랜드 분석 결과로 goToWizardStep(0); // 브랜드 분석 결과로
@ -238,7 +245,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
fetch(jsonFile) fetch(jsonFile)
.then(res => res.json()) .then(res => res.json())
.then((data: CrawlingResponse) => { .then((data: CrawlingResponse) => {
const tagged = { ...data, _isTestData: true }; // 언어 변경 시에는 기존 m_id 유지
const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
setAnalysisData(tagged); setAnalysisData(tagged);
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged)); localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
}) })
@ -255,6 +263,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
try { try {
const data = await crawlUrl(url); const data = await crawlUrl(url);
console.log('[Crawl] Response m_id:', data.m_id);
// 기본값 보장 // 기본값 보장
if (data.marketing_analysis) { if (data.marketing_analysis) {

View File

@ -64,6 +64,17 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const languageDropdownRef = useRef<HTMLDivElement>(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 // Close language dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@ -84,6 +95,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
// Auto-navigate to next page when video generation is complete // Auto-navigate to next page when video generation is complete
useEffect(() => { useEffect(() => {
if (videoGenerationStatus === 'complete' && songTaskId) { if (videoGenerationStatus === 'complete' && songTaskId) {
// Save completion data before navigating
saveSongCompletionData();
// Wait a brief moment to show 100% completion before navigating // Wait a brief moment to show 100% completion before navigating
const timer = setTimeout(() => { const timer = setTimeout(() => {
onNext(songTaskId); onNext(songTaskId);
@ -277,6 +290,7 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
const language = LANGUAGE_MAP[selectedLang] || 'Korean'; const language = LANGUAGE_MAP[selectedLang] || 'Korean';
// 1. 가사 생성 요청 // 1. 가사 생성 요청
console.log('[SoundStudio] Sending m_id:', mId);
const lyricResponse = await generateLyric({ const lyricResponse = await generateLyric({
customer_name: businessInfo.customer_name, customer_name: businessInfo.customer_name,
detail_region_info: businessInfo.detail_region_info, 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.soundTypeVocal') },
{ key: '배경음악', label: t('soundStudio.soundTypeBGM') }, { key: '배경음악', label: t('soundStudio.soundTypeBGM') },
{ key: '성우 내레이션', label: t('soundStudio.soundTypeNarration') }, ].map(({ key, label }) => (
].map(({ key, label }) => {
const isDisabled = key === '성우 내레이션' || isGenerating;
return (
<button <button
key={key} key={key}
onClick={() => !isDisabled && setSelectedType(key)} onClick={() => !isGenerating && setSelectedType(key)}
disabled={isDisabled} disabled={isGenerating}
className={`sound-type-btn ${selectedType === key ? 'active' : ''} ${key === '성우 내레이션' ? 'permanently-disabled' : ''}`} className={`sound-type-btn ${selectedType === key ? 'active' : ''}`}
> >
{label} {label}
</button> </button>
); ))}
})}
</div> </div>
</div> </div>
@ -608,7 +618,12 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{/* Bottom Button */} {/* Bottom Button */}
<div className="bottom-button-container"> <div className="bottom-button-container">
<button <button
onClick={() => songTaskId && onNext(songTaskId)} onClick={() => {
if (songTaskId) {
saveSongCompletionData();
onNext(songTaskId);
}
}}
disabled={status !== 'complete' || !songTaskId || videoGenerationStatus === 'generating'} disabled={status !== 'complete' || !songTaskId || videoGenerationStatus === 'generating'}
className={`btn-video-generate ${ className={`btn-video-generate ${
videoGenerationStatus === 'generating' videoGenerationStatus === 'generating'