500 lines
17 KiB
TypeScript
Executable File
500 lines
17 KiB
TypeScript
Executable File
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { CrawlingResponse, TargetPersona, SellingPoint } from '../../types/api';
|
|
|
|
interface AnalysisResultSectionProps {
|
|
onBack: () => void;
|
|
onGenerate?: () => void;
|
|
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 [animatedScores, setAnimatedScores] = useState<number[]>(data.map(() => 0));
|
|
const [isAnimating, setIsAnimating] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const targetScores = data.map(item => item.score);
|
|
const duration = 1500;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
const easeProgress = 1 - Math.pow(1 - progress, 3);
|
|
|
|
const newScores = targetScores.map(target => target * easeProgress);
|
|
setAnimatedScores(newScores);
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
} else {
|
|
setIsAnimating(false);
|
|
}
|
|
};
|
|
|
|
requestAnimationFrame(animate);
|
|
}, [data]);
|
|
|
|
const padding = 60;
|
|
const center = size / 2 + padding;
|
|
const maxRadius = size / 2 - 20;
|
|
const levels = 5;
|
|
const angleStep = (2 * Math.PI) / data.length;
|
|
|
|
const getPoint = (index: number, score: number) => {
|
|
const angle = angleStep * index - Math.PI / 2;
|
|
const radius = (score / 100) * maxRadius;
|
|
return {
|
|
x: center + radius * Math.cos(angle),
|
|
y: center + radius * Math.sin(angle),
|
|
};
|
|
};
|
|
|
|
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';
|
|
|
|
const levelPaths = Array.from({ length: levels }, (_, levelIndex) => {
|
|
const levelRadius = ((levelIndex + 1) / levels) * maxRadius;
|
|
const points = data.map((_, i) => {
|
|
const angle = angleStep * i - Math.PI / 2;
|
|
return {
|
|
x: center + levelRadius * Math.cos(angle),
|
|
y: center + levelRadius * Math.sin(angle),
|
|
};
|
|
});
|
|
return points.map((p, i) => (i === 0 ? `M ${p.x} ${p.y}` : `L ${p.x} ${p.y}`)).join(' ') + ' Z';
|
|
});
|
|
|
|
const axisLines = data.map((_, i) => {
|
|
const angle = angleStep * i - Math.PI / 2;
|
|
const endX = center + maxRadius * Math.cos(angle);
|
|
const endY = center + maxRadius * Math.sin(angle);
|
|
return `M ${center} ${center} L ${endX} ${endY}`;
|
|
});
|
|
|
|
const svgSize = size + padding * 2;
|
|
|
|
return (
|
|
<div className="relative flex justify-center items-center" style={{ overflow: 'visible' }}>
|
|
<svg
|
|
width={svgSize}
|
|
height={svgSize}
|
|
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
|
style={{ overflow: 'visible' }}
|
|
>
|
|
{levelPaths.map((path, i) => (
|
|
<path
|
|
key={`level-${i}`}
|
|
d={path}
|
|
fill="none"
|
|
stroke="rgba(255,255,255,0.1)"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
{axisLines.map((path, i) => (
|
|
<path
|
|
key={`axis-${i}`}
|
|
d={path}
|
|
fill="none"
|
|
stroke="rgba(255,255,255,0.1)"
|
|
strokeWidth="1"
|
|
/>
|
|
))}
|
|
|
|
<path
|
|
d={dataPath}
|
|
fill="rgba(45, 212, 191, 0.2)"
|
|
stroke="#2dd4bf"
|
|
strokeWidth="2"
|
|
style={{
|
|
transition: isAnimating ? 'none' : 'd 0.3s ease-out',
|
|
}}
|
|
/>
|
|
|
|
{dataPoints.map((point, i) => (
|
|
<circle
|
|
key={`point-${i}`}
|
|
cx={point.x}
|
|
cy={point.y}
|
|
r="4"
|
|
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;
|
|
|
|
return (
|
|
<g key={`label-${i}`}>
|
|
<text
|
|
x={pos.x}
|
|
y={pos.y}
|
|
dy={dy}
|
|
textAnchor={textAnchor}
|
|
className="bi-chart-label"
|
|
>
|
|
{item.korean_category}
|
|
</text>
|
|
<text
|
|
x={pos.x}
|
|
y={pos.y}
|
|
dy={dy + 16}
|
|
textAnchor={textAnchor}
|
|
className="bi-chart-sublabel"
|
|
>
|
|
{item.english_category}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
|
|
const { processed_info, marketing_analysis } = data;
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [buttonPosition, setButtonPosition] = useState({ left: 0, width: 0 });
|
|
|
|
const brandIdentity = marketing_analysis?.brand_identity;
|
|
const marketPositioning = marketing_analysis?.market_positioning;
|
|
const targetPersonas = marketing_analysis?.target_persona || [];
|
|
const sellingPoints = marketing_analysis?.selling_points || [];
|
|
const targetKeywords = marketing_analysis?.target_keywords || [];
|
|
|
|
useEffect(() => {
|
|
const updateButtonPosition = () => {
|
|
if (containerRef.current) {
|
|
const rect = containerRef.current.getBoundingClientRect();
|
|
setButtonPosition({ left: rect.left, width: rect.width });
|
|
}
|
|
};
|
|
|
|
updateButtonPosition();
|
|
window.addEventListener('resize', updateButtonPosition);
|
|
|
|
const observer = new MutationObserver(updateButtonPosition);
|
|
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['class'] });
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', updateButtonPosition);
|
|
observer.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
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">
|
|
{/* Header */}
|
|
<div className="asset-header">
|
|
<button onClick={onBack} className="btn-back-new">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M15 18l-6-6 6-6" />
|
|
</svg>
|
|
<span>뒤로가기</span>
|
|
</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" />
|
|
</div>
|
|
<h1 className="bi-page-title">브랜드 인텔리전스</h1>
|
|
<p className="bi-page-desc">
|
|
<span className="highlight">AI 데이터 분석</span>을 통해 도출된 {processed_info?.customer_name || '브랜드'}의 핵심 전략입니다.
|
|
</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">
|
|
브랜드 정체성
|
|
</div>
|
|
|
|
<h2 className="bi-brand-name">{processed_info?.customer_name || '브랜드명'}</h2>
|
|
|
|
<div className="bi-location mb-6">
|
|
<MapPinIcon />
|
|
<div>
|
|
<p>{processed_info?.detail_region_info || '주소 정보 없음'}</p>
|
|
<p style={{ opacity: 0.7, marginTop: '4px' }}>{processed_info?.region || ''}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6 border-t border-white/5 pt-6">
|
|
<AnimatedItem index={0} baseDelay={100}>
|
|
<div className="bi-subsection-title">입지 특성 분석</div>
|
|
<p className="bi-body-text">{brandIdentity?.location_feature_analysis || '정보 없음'}</p>
|
|
</AnimatedItem>
|
|
<AnimatedItem index={1} baseDelay={100}>
|
|
<div className="bi-subsection-title">컨셉 확장성</div>
|
|
<p className="bi-body-text">{brandIdentity?.concept_scalability || '정보 없음'}</p>
|
|
</AnimatedItem>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 시장 포지셔닝 카드 */}
|
|
<div className="bi-card">
|
|
<h3 className="bi-card-title">
|
|
시장 포지셔닝
|
|
</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">핵심 가치 (Core Value)</div>
|
|
<div className="bi-value">{marketPositioning?.core_value || '정보 없음'}</div>
|
|
</div>
|
|
</AnimatedItem>
|
|
<AnimatedItem index={1} baseDelay={200}>
|
|
<div className="bi-inner-box">
|
|
<div className="bi-subsection-title" style={{ color: '#6AB0B3' }}>카테고리 정의</div>
|
|
<div className="bi-value-large">{marketPositioning?.category_definition || '정보 없음'}</div>
|
|
</div>
|
|
</AnimatedItem>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 타겟 페르소나 카드 */}
|
|
<div className="bi-card">
|
|
<h3 className="bi-card-title">
|
|
타겟 페르소나
|
|
</h3>
|
|
<div className="space-y-4">
|
|
{targetPersonas.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</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}세</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">
|
|
주요 셀링 포인트 (USP)
|
|
</h3>
|
|
|
|
{/* 레이더 차트 */}
|
|
{sellingPoints.length > 0 && (
|
|
<div className="mb-8">
|
|
<RadarChart data={sellingPoints} size={280} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Top USP 하이라이트 */}
|
|
{topUSP && (
|
|
<AnimatedUSPItem
|
|
usp={topUSP}
|
|
index={0}
|
|
isTop={true}
|
|
/>
|
|
)}
|
|
|
|
{/* 나머지 USP 리스트 */}
|
|
<div className="space-y-4 flex-1">
|
|
{sellingPoints.length === 0 && <p className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</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>
|
|
</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">추천 타겟 키워드</h3>
|
|
<div className="flex flex-wrap gap-3">
|
|
{targetKeywords.length === 0 && <span className="bi-body-text" style={{ color: '#6AB0B3' }}>정보 없음</span>}
|
|
{targetKeywords.map((keyword: string, idx: number) => (
|
|
<span key={idx} className="bi-tag-outline"># {keyword}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 콘텐츠 생성 버튼 */}
|
|
<div
|
|
className="fixed bottom-8 flex justify-center z-50 pointer-events-none"
|
|
style={{ left: buttonPosition.left, width: buttonPosition.width }}
|
|
>
|
|
<button
|
|
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" />
|
|
콘텐츠 생성
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AnalysisResultSection;
|