o2o-castad-frontend/src/pages/Analysis/AnalysisResultSection.tsx

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;