ado2 삭제 부분 수정, 브랜드 페이지 작업, 버그 fix .

main
hbyang 2026-01-30 11:25:56 +09:00
parent e997b2b5af
commit cac825de19
5 changed files with 641 additions and 355 deletions

109
analysisResult.json Normal file
View File

@ -0,0 +1,109 @@
{
"marketing_analysis": {
"brand_identity": {
"location_feature_analysis": "전북 군산시 절골길 일대는 도시의 편의성과 근교의 한적함을 동시에 누릴 수 있어 ‘조용한 재충전’ 수요에 적합합니다. 군산의 레트로 감성과 주변 관광 동선 결합이 쉬워 1~2박 체류형 여행지로 매력적입니다.",
"concept_scalability": "‘머뭄’이라는 네이밍을 ‘잠시 멈춰 머무는 시간’으로 확장해, 느린 체크인·명상/독서 큐레이션·로컬 티/다과 등 체류 경험형 서비스로 고도화가 가능합니다. 로컬 콘텐츠(군산 빵/커피, 근대문화 투어)와 결합해 패키지화하면 재방문 명분을 만들 수 있습니다."
},
"market_positioning": {
"category_definition": "군산 감성 ‘슬로우 스테이’ 프라이빗 숙소",
"core_value": "아무것도 하지 않아도 회복되는 ‘멈춤의 시간’"
},
"target_persona": [
{
"persona": "번아웃 회복형 직장인 커플: 주말에 조용히 쉬며 리셋을 원하는 2인 여행자",
"age": {
"min_age": 27,
"max_age": 39
},
"favor_target": [
"조용한 동네 분위기",
"미니멀/내추럴 인테리어",
"편안한 침구와 숙면 환경",
"셀프 체크인 선호",
"카페·맛집 연계 동선"
],
"decision_trigger": "‘조용히 쉬는 데 최적화’된 프라이빗함과 숙면 컨디션(침구/동선/소음 차단) 확신"
},
{
"persona": "감성 기록형 친구 여행: 사진과 무드를 위해 공간을 선택하는 2~3인 여성 그룹",
"age": {
"min_age": 23,
"max_age": 34
},
"favor_target": [
"자연광 좋은 공간",
"감성 소품/컬러 톤",
"포토존(거울/창가/테이블)",
"와인·디저트 페어링",
"야간 무드 조명"
],
"decision_trigger": "사진이 ‘그대로 작품’이 되는 포토 스팟과 야간 무드 연출 요소"
},
{
"persona": "로컬 탐험형 소도시 여행자: 군산의 레트로/로컬 콘텐츠를 깊게 즐기는 커플·솔로",
"age": {
"min_age": 28,
"max_age": 45
},
"favor_target": [
"근대문화/레트로 감성",
"로컬 맛집·빵집 투어",
"동선 효율(차로 이동 용이)",
"체크아웃 후 관광 연계",
"조용한 밤"
],
"decision_trigger": "‘군산 로컬 동선’과 결합하기 좋은 위치 + 숙소 자체의 휴식 완성도"
}
],
"selling_points": [
{
"category": "LOCATION",
"description": "군산 감성 동선",
"score": 88
},
{
"category": "HEALING",
"description": "멈춤이 되는 쉼",
"score": 92
},
{
"category": "PRIVACY",
"description": "방해 없는 머뭄",
"score": 86
},
{
"category": "NIGHT MOOD",
"description": "밤이 예쁜 조명",
"score": 84
},
{
"category": "PHOTO SPOT",
"description": "자연광 포토존",
"score": 83
},
{
"category": "SHORT GETAWAY",
"description": "주말 리셋 스테이",
"score": 89
},
{
"category": "HOSPITALITY",
"description": "세심한 웰컴감",
"score": 80
}
],
"target_keywords": [
"군산숙소",
"군산감성숙소",
"전북숙소추천",
"군산여행",
"커플스테이",
"주말여행",
"감성스테이",
"조용한숙소",
"힐링스테이",
"스테이머뭄"
]
}
}

View File

@ -53,9 +53,17 @@ const App: React.FC = () => {
const data = JSON.parse(savedAnalysisData) as CrawlingResponse; const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
// 기본값 보장 // 기본값 보장
if (data.marketing_analysis) { if (data.marketing_analysis) {
data.marketing_analysis.tags = data.marketing_analysis.tags || []; data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
data.marketing_analysis.facilities = data.marketing_analysis.facilities || []; location_feature_analysis: '',
data.marketing_analysis.report = data.marketing_analysis.report || ''; concept_scalability: ''
};
data.marketing_analysis.market_positioning = data.marketing_analysis.market_positioning || {
category_definition: '',
core_value: ''
};
data.marketing_analysis.target_persona = data.marketing_analysis.target_persona || [];
data.marketing_analysis.selling_points = data.marketing_analysis.selling_points || [];
data.marketing_analysis.target_keywords = data.marketing_analysis.target_keywords || [];
} }
if (data.processed_info) { if (data.processed_info) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음'; data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
@ -197,14 +205,26 @@ const App: React.FC = () => {
if (!data.marketing_analysis) return false; if (!data.marketing_analysis) return false;
// marketing_analysis 내부 필드 기본값 보장 // marketing_analysis 내부 필드 기본값 보장
if (!data.marketing_analysis.tags) { if (!data.marketing_analysis.brand_identity) {
data.marketing_analysis.tags = []; data.marketing_analysis.brand_identity = {
location_feature_analysis: '',
concept_scalability: ''
};
} }
if (!data.marketing_analysis.facilities) { if (!data.marketing_analysis.market_positioning) {
data.marketing_analysis.facilities = []; data.marketing_analysis.market_positioning = {
category_definition: '',
core_value: ''
};
} }
if (!data.marketing_analysis.report) { if (!data.marketing_analysis.target_persona) {
data.marketing_analysis.report = ''; data.marketing_analysis.target_persona = [];
}
if (!data.marketing_analysis.selling_points) {
data.marketing_analysis.selling_points = [];
}
if (!data.marketing_analysis.target_keywords) {
data.marketing_analysis.target_keywords = [];
} }
// processed_info 내부 필드 기본값 보장 // processed_info 내부 필드 기본값 보장

View File

@ -1,7 +1,6 @@
import React, { useMemo } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { CrawlingResponse } from '../../types/api'; import { CrawlingResponse, TargetPersona, SellingPoint } from '../../types/api';
import GeometricChart, { USP } from './GeometricChart';
interface AnalysisResultSectionProps { interface AnalysisResultSectionProps {
onBack: () => void; onBack: () => void;
@ -9,242 +8,6 @@ interface AnalysisResultSectionProps {
data: CrawlingResponse; data: CrawlingResponse;
} }
type MarkdownBlock =
| { type: 'heading'; level: number; text: string }
| { type: 'list'; ordered: boolean; items: string[] }
| { type: 'paragraph'; text: string };
const parseMarkdownBlocks = (text: string): MarkdownBlock[] => {
const lines = text.split(/\r?\n/);
const blocks: MarkdownBlock[] = [];
let paragraphLines: string[] = [];
let listItems: string[] = [];
let listOrdered = false;
const flushParagraph = () => {
if (paragraphLines.length === 0) return;
blocks.push({ type: 'paragraph', text: paragraphLines.join(' ') });
paragraphLines = [];
};
const flushList = () => {
if (listItems.length === 0) return;
blocks.push({ type: 'list', ordered: listOrdered, items: listItems });
listItems = [];
listOrdered = false;
};
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) {
flushParagraph();
flushList();
return;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushParagraph();
flushList();
blocks.push({
type: 'heading',
level: headingMatch[1].length,
text: headingMatch[2].trim(),
});
return;
}
const listMatch = trimmed.match(/^([-*+]|\d+\.)\s+(.*)$/);
if (listMatch) {
flushParagraph();
listOrdered = listMatch[1].endsWith('.');
listItems.push(listMatch[2].trim());
return;
}
flushList();
paragraphLines.push(trimmed);
});
flushParagraph();
flushList();
return blocks;
};
const renderInlineMarkdown = (text: string): React.ReactNode[] => {
// Use non-greedy .+? instead of [^*]+ to handle edge cases better
const parts = text.split(/(\*\*.+?\*\*|`[^`]+`|#[^\s#]+)/g).filter(Boolean);
return parts.map((part, idx) => {
if (part.startsWith('**') && part.endsWith('**')) {
return (
<strong key={idx} className="text-white font-semibold">
{part.slice(2, -2)}
</strong>
);
}
if (part.startsWith('`') && part.endsWith('`')) {
return (
<code key={idx} className="px-1 py-0.5 rounded bg-brand-bg/60 text-brand-accent text-xs">
{part.slice(1, -1)}
</code>
);
}
if (part.startsWith('#')) {
return (
<span key={idx} className="text-brand-purple font-semibold">
{part}
</span>
);
}
return <span key={idx}>{part}</span>;
});
};
const renderMarkdown = (text: string) => {
const blocks = parseMarkdownBlocks(text);
return blocks.map((block, idx) => {
if (block.type === 'heading') {
return (
<h4
key={idx}
className="text-sm font-semibold text-brand-accent uppercase tracking-wider mt-4 first:mt-0"
>
{block.text}
</h4>
);
}
if (block.type === 'list') {
const ListTag = block.ordered ? 'ol' : 'ul';
const listClass = block.ordered ? 'list-decimal' : 'list-disc';
return (
<ListTag key={idx} className={`space-y-1 text-sm text-brand-text/90 ${listClass} pl-4`}>
{block.items.map((item, itemIdx) => (
<li key={itemIdx}>{renderInlineMarkdown(item)}</li>
))}
</ListTag>
);
}
return (
<p key={idx} className="text-sm text-brand-text/80 leading-relaxed">
{renderInlineMarkdown(block.text)}
</p>
);
});
};
const splitMarkdownSections = (text: string) => {
const blocks = parseMarkdownBlocks(text);
const sections: Array<{ title: string; content: string }> = [];
let currentTitle = '본문';
let contentLines: string[] = [];
blocks.forEach((block) => {
if (block.type === 'heading') {
if (contentLines.length > 0) {
sections.push({ title: currentTitle, content: contentLines.join('\n') });
}
currentTitle = block.text;
contentLines = [];
return;
}
if (block.type === 'list') {
contentLines.push(block.items.map((item) => `- ${item}`).join('\n'));
return;
}
contentLines.push(block.text);
});
if (contentLines.length > 0) {
sections.push({ title: currentTitle, content: contentLines.join('\n') });
}
return sections;
};
const pickSectionContent = (sections: Array<{ title: string; content: string }>, keywords: string[]) => {
const lowered = keywords.map((keyword) => keyword.toLowerCase());
const match = sections.find((section) =>
lowered.some((keyword) => section.title.toLowerCase().includes(keyword))
);
return match?.content || '';
};
const buildUSPs = (facilities: string[], tags: string[]) => {
const candidates = [...facilities, ...tags.filter((tag) => !facilities.includes(tag))];
const labels = candidates.slice(0, 7);
const subLabels = ['CONCEPT', 'PRIVACY', 'LOCAL', 'MOOD', 'TRUST', 'STAY', 'PHOTO', 'HEALING'];
if (labels.length < 3) {
return [
{
label: '브랜드 컨셉',
subLabel: 'CONCEPT',
score: 88,
description: '분석 데이터 기반 컨셉 도출',
},
{
label: '프라이버시',
subLabel: 'PRIVACY',
score: 84,
description: '프라이빗 경험 강조 포인트',
},
{
label: '무드',
subLabel: 'MOOD',
score: 82,
description: '감성적 장면 강조',
},
];
}
return labels.map((label, idx) => ({
label,
subLabel: subLabels[idx % subLabels.length],
score: Math.min(96, 78 + idx * 3 + (label.length % 6)),
description: tags[idx] ? `키워드: ${tags[idx]}` : label,
}));
};
const buildTargets = (sectionText: string, tags: string[]) => {
if (!sectionText.trim()) {
return [
{
segment: '타겟 고객',
age: '',
needs: tags.slice(0, 3),
triggers: [],
},
];
}
const blocks = parseMarkdownBlocks(sectionText);
const listBlock = blocks.find((block) => block.type === 'list') as
| { type: 'list'; ordered: boolean; items: string[] }
| undefined;
const rawItems = listBlock?.items ?? sectionText.split(/\r?\n/).filter(Boolean);
return rawItems.slice(0, 3).map((item, idx) => {
const [segmentPart, detailPart] = item.split(':');
const segment = (segmentPart || item).trim();
const detail = (detailPart || '').trim();
const needs = detail
? detail
.split(/[,/·]/)
.map((value) => value.trim())
.filter(Boolean)
: tags.slice(idx * 2, idx * 2 + 3);
const ageMatch = segment.match(/(\d{2}~\d{2}세|\d{2}대|\d{2}s)/);
return {
segment,
age: ageMatch ? ageMatch[0] : '',
needs,
triggers: [],
};
});
};
const SparklesIcon = ({ className = '' }: { className?: string }) => ( const SparklesIcon = ({ className = '' }: { className?: string }) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden> <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" /> <path d="M12 2l2.4 6.8L22 12l-7.6 3.2L12 22l-2.4-6.8L2 12l7.6-3.2L12 2z" />
@ -274,13 +37,6 @@ const UsersIcon = () => (
</svg> </svg>
); );
const TrendingUpIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 17l6-6 4 4 7-7" />
<path d="M14 7h7v7" />
</svg>
);
const LayoutGridIcon = () => ( const LayoutGridIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" /> <rect x="3" y="3" width="7" height="7" />
@ -290,28 +46,388 @@ const LayoutGridIcon = () => (
</svg> </svg>
); );
const ChartIcon = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 20V10M12 20V4M6 20v-6" />
</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>
);
};
// 애니메이션 숫자 카운터 컴포넌트
interface AnimatedScoreProps {
score: number;
duration?: number;
delay?: number;
className?: string;
}
const AnimatedScore: React.FC<AnimatedScoreProps> = ({ score, duration = 1000, delay = 0, className = '' }) => {
const [displayScore, setDisplayScore] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
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);
setDisplayScore(Math.round(score * easeProgress));
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}, delay);
return () => clearTimeout(timer);
}, [score, duration, delay]);
return <span className={className}>{displayScore}</span>;
};
// 애니메이션 USP 아이템 컴포넌트
interface AnimatedUSPItemProps {
usp: { category: string; description: string; score: number };
index: number;
isTop?: boolean;
getScoreColor: (score: number) => string;
}
const AnimatedUSPItem: React.FC<AnimatedUSPItemProps> = ({ usp, index, isTop = false, getScoreColor }) => {
const [isVisible, setIsVisible] = useState(false);
const delay = index * 150; // 각 아이템마다 150ms 딜레이
useEffect(() => {
const timer = setTimeout(() => {
setIsVisible(true);
}, delay);
return () => clearTimeout(timer);
}, [delay]);
if (isTop) {
return (
<div
className={`mb-6 p-5 rounded-xl bg-gradient-to-r from-brand-accent/10 to-brand-purple/10 border border-brand-accent/30 relative overflow-hidden transition-all duration-500 ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
}`}
>
<div className="absolute top-0 right-0 w-20 h-20 bg-brand-accent/5 rounded-full -translate-y-1/2 translate-x-1/2"></div>
<div className="relative z-10 flex justify-between items-center">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-brand-accent font-bold uppercase tracking-wider">{usp.category}</span>
<span className="text-xs bg-brand-accent/20 text-brand-accent px-2 py-0.5 rounded font-semibold">TOP</span>
</div>
<div className="text-lg font-bold text-white">{usp.description}</div>
</div>
<AnimatedScore
score={usp.score}
delay={delay + 300}
duration={1200}
className={`text-4xl font-bold ${getScoreColor(usp.score)}`}
/>
</div>
</div>
);
}
return (
<div
className={`p-4 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-all duration-500 flex justify-between items-center ${
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4'
}`}
>
<div>
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight mb-1">{usp.category}</div>
<div className="text-base font-bold text-white">{usp.description}</div>
</div>
<AnimatedScore
score={usp.score}
delay={delay + 300}
duration={1000}
className={`text-3xl font-bold ${getScoreColor(usp.score)} ml-4`}
/>
</div>
);
};
// 레이더 차트 컴포넌트
interface RadarChartProps {
data: { category: string; description: string; score: number }[];
size?: number;
}
const RadarChart: React.FC<RadarChartProps> = ({ data, size = 280 }) => {
// 애니메이션을 위한 현재 점수 상태 (0에서 시작)
const [animatedScores, setAnimatedScores] = useState<number[]>(data.map(() => 0));
const [isAnimating, setIsAnimating] = useState(true);
// 애니메이션 효과
useEffect(() => {
const targetScores = data.map(item => item.score);
const duration = 1500; // 1.5초
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeOutCubic 이징 함수 적용
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; // padding 만큼 오프셋
const maxRadius = size / 2 - 20; // 차트 반지름
const levels = 5; // 동심원 개수
// 각 축의 각도 계산
const angleStep = (2 * Math.PI) / data.length;
// 점수를 좌표로 변환 (0-100 점수를 반지름으로)
const getPoint = (index: number, score: number) => {
const angle = angleStep * index - Math.PI / 2; // -90도에서 시작 (12시 방향)
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 + 20;
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}`;
});
// SVG 전체 크기 (padding 포함)
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}
fill="rgba(255,255,255,0.9)"
fontSize="11"
fontWeight="600"
>
{item.description}
</text>
{/* 영어 카테고리 (서브 라벨) */}
<text
x={pos.x}
y={pos.y}
dy={dy + 12}
textAnchor={textAnchor}
fill="rgba(255,255,255,0.4)"
fontSize="8"
fontWeight="400"
>
{item.category}
</text>
</g>
);
})}
</svg>
</div>
);
};
const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => { const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, onGenerate, data }) => {
const { processed_info, marketing_analysis } = data; const { processed_info, marketing_analysis } = data;
const tags = marketing_analysis?.tags || []; const containerRef = useRef<HTMLDivElement>(null);
const facilities = marketing_analysis?.facilities || []; const [buttonPosition, setButtonPosition] = useState({ left: 0, width: 0 });
const reportSections = useMemo(
() => splitMarkdownSections(marketing_analysis?.report || ''), const brandIdentity = marketing_analysis?.brand_identity;
[marketing_analysis?.report] const marketPositioning = marketing_analysis?.market_positioning;
); const targetPersonas = marketing_analysis?.target_persona || [];
const locationAnalysis = const sellingPoints = marketing_analysis?.selling_points || [];
pickSectionContent(reportSections, ['지역', '입지']) || reportSections[0]?.content || ''; const targetKeywords = marketing_analysis?.target_keywords || [];
const conceptAnalysis =
pickSectionContent(reportSections, ['핵심', '차별', '컨셉', '콘셉트']) || reportSections[1]?.content || ''; // 컨테이너 기준으로 버튼 위치 계산
const targetSection = pickSectionContent(reportSections, ['타겟', '고객']); useEffect(() => {
const positioningCategory = facilities.length > 0 ? facilities.join(' · ') : '정보 없음'; const updateButtonPosition = () => {
const positioningCore = if (containerRef.current) {
pickSectionContent(reportSections, ['핵심 가치', '핵심', '차별']) || tags.slice(0, 3).join(' · ') || '정보 없음'; const rect = containerRef.current.getBoundingClientRect();
const usps: USP[] = useMemo(() => buildUSPs(facilities, tags), [facilities, tags]); setButtonPosition({ left: rect.left, width: rect.width });
const topUSP = useMemo( }
() => [...usps].sort((a, b) => b.score - a.score)[0], };
[usps]
); updateButtonPosition();
const targets = useMemo(() => buildTargets(targetSection, tags), [targetSection, tags]); window.addEventListener('resize', updateButtonPosition);
// MutationObserver로 사이드바 변화 감지
const observer = new MutationObserver(updateButtonPosition);
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['class'] });
return () => {
window.removeEventListener('resize', updateButtonPosition);
observer.disconnect();
};
}, []);
// 가장 높은 점수의 USP 찾기
const topUSP = sellingPoints.length > 0
? [...sellingPoints].sort((a, b) => b.score - a.score)[0]
: null;
// 점수에 따른 색상
const getScoreColor = (score: number) => {
if (score >= 90) return 'text-brand-accent';
if (score >= 85) return 'text-green-400';
if (score >= 80) return 'text-yellow-400';
return 'text-gray-400';
};
return ( return (
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans"> <div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
@ -336,8 +452,10 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</p> </p>
</div> </div>
<div className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8"> <div ref={containerRef} className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* 왼쪽 컬럼 */}
<div className="space-y-6"> <div className="space-y-6">
{/* 브랜드 정체성 카드 */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500"> <div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500">
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div> <div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div>
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
@ -356,131 +474,138 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</div> </div>
<div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5"> <div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5">
<div> <AnimatedItem index={0} baseDelay={100}>
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3> <h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3>
{locationAnalysis ? renderMarkdown(locationAnalysis) : <p className="text-sm opacity-90"> </p>} <p className="text-sm opacity-90">{brandIdentity?.location_feature_analysis || '정보 없음'}</p>
</div> </AnimatedItem>
<div> <AnimatedItem index={1} baseDelay={100}>
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3> <h3 className="text-white font-semibold mb-2 text-sm text-brand-muted"> </h3>
{conceptAnalysis ? renderMarkdown(conceptAnalysis) : <p className="text-sm opacity-90"> </p>} <p className="text-sm opacity-90">{brandIdentity?.concept_scalability || '정보 없음'}</p>
</div> </AnimatedItem>
</div> </div>
</div> </div>
{/* 시장 포지셔닝 카드 */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10"> <div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white"> <h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
<TargetIcon /> <TargetIcon />
</h3> </h3>
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<AnimatedItem index={0} baseDelay={200}>
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
<span className="block text-xs text-brand-accent mb-1 font-semibold"> (Core Value)</span>
<div className="font-semibold text-white">{marketPositioning?.core_value || '정보 없음'}</div>
</div>
</AnimatedItem>
<AnimatedItem index={1} baseDelay={200}>
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group"> <div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors"> <span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
</span> </span>
<div className="font-bold text-lg text-white">{renderMarkdown(positioningCategory)}</div> <div className="font-bold text-lg text-white">{marketPositioning?.category_definition || '정보 없음'}</div>
</div>
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
<span className="block text-xs text-brand-accent mb-1 font-semibold"> (Core Value)</span>
<div className="font-semibold text-white">{renderMarkdown(positioningCore)}</div>
</div> </div>
</AnimatedItem>
</div> </div>
</div> </div>
{/* 타겟 페르소나 카드 */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10"> <div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white"> <h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
<UsersIcon /> <UsersIcon />
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{targets.map((target, idx) => ( {targetPersonas.length === 0 && <p className="text-sm text-brand-muted"> </p>}
<div {targetPersonas.map((persona: TargetPersona, idx: number) => (
key={idx} <AnimatedItem key={idx} index={idx} baseDelay={300}>
className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group" <div className="p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group">
> <div className="mb-3">
<div className="min-w-[120px]"> <div className="font-bold text-white group-hover:text-brand-accent transition-colors mb-1">
<div className="font-bold text-white group-hover:text-brand-accent transition-colors"> {persona.persona}
{renderInlineMarkdown(target.segment)}
</div> </div>
{target.age && <div className="text-xs text-brand-muted">{target.age}</div>} <div className="text-xs text-brand-muted">
{persona.age.min_age}~{persona.age.max_age}
</div> </div>
<div className="flex-1"> </div>
{target.needs.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2"> {persona.favor_target.length > 0 && (
{target.needs.map((need, i) => ( <div className="flex flex-wrap gap-1.5 mb-3">
{persona.favor_target.map((favor: string, i: number) => (
<span <span
key={i} key={i}
className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium" className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium"
> >
{renderInlineMarkdown(need)} {favor}
</span> </span>
))} ))}
</div> </div>
)} )}
{target.triggers.length > 0 && (
{persona.decision_trigger && (
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2"> <p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
<span className="text-brand-muted">Trigger:</span> {target.triggers.map((t: string, i: number) => <span key={i}>{i > 0 && ', '}{renderInlineMarkdown(t)}</span>)} <span className="text-brand-muted font-semibold">Trigger:</span> {persona.decision_trigger}
</p> </p>
)} )}
</div> </div>
</div> </AnimatedItem>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* 오른쪽 컬럼 */}
<div className="space-y-6"> <div className="space-y-6">
{/* 주요 셀링 포인트 카드 */}
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden"> <div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
<div className="flex justify-between items-center mb-2 z-10"> <div className="flex justify-between items-center mb-6 z-10">
<h3 className="text-xl font-bold text-white"> (USP)</h3> <h3 className="text-xl font-bold text-white flex items-center gap-2">
<ChartIcon /> (USP)
</h3>
</div> </div>
<div className="flex-1 flex items-center justify-center relative z-10 -my-4"> {/* 레이더 차트 */}
<GeometricChart data={usps} /> {sellingPoints.length > 0 && (
</div> <div className="mb-6">
<RadarChart data={sellingPoints} size={320} />
{topUSP && (
<div className="mt-2 mb-6 p-4 rounded-xl bg-brand-card border border-brand-muted/20 relative overflow-hidden">
<div className="relative z-10 flex justify-between items-center">
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-brand-accent font-bold uppercase tracking-wider">{topUSP.subLabel}</span>
<span className="text-xs bg-brand-muted/30 text-white px-2 py-0.5 rounded font-semibold">CORE</span>
</div>
<div className="text-lg font-bold text-white mb-1">{topUSP.label}</div>
<div className="text-sm text-brand-muted">{topUSP.description}</div>
</div>
<div className="text-4xl font-bold text-brand-muted">{topUSP.score}</div>
</div>
</div> </div>
)} )}
<div className="space-y-3 z-10"> {/* Top USP 하이라이트 */}
{usps {topUSP && (
.filter((usp) => usp.label !== topUSP?.label) <AnimatedUSPItem
.map((usp, idx) => ( usp={topUSP}
<div index={0}
isTop={true}
getScoreColor={getScoreColor}
/>
)}
{/* 나머지 USP 리스트 */}
<div className="space-y-3 z-10 flex-1">
{sellingPoints.length === 0 && <p className="text-sm text-brand-muted"> </p>}
{sellingPoints
.filter((usp: SellingPoint) => usp.category !== topUSP?.category)
.map((usp: SellingPoint, idx: number) => (
<AnimatedUSPItem
key={idx} key={idx}
className="p-4 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors flex justify-between items-center" usp={usp}
> index={idx + 1}
<div> isTop={false}
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight mb-1">{usp.subLabel}</div> getScoreColor={getScoreColor}
<div className="text-base font-bold text-white mb-1">{usp.label}</div> />
<div className="text-sm text-gray-400">{usp.description}</div>
</div>
<div className="text-3xl font-bold text-brand-muted ml-4">{usp.score}</div>
</div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 추천 타겟 키워드 */}
<div className="max-w-7xl mx-auto px-4 md:px-8 mt-8"> <div className="max-w-7xl mx-auto px-4 md:px-8 mt-8">
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden"> <div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
<h3 className="text-xl font-bold mb-6 text-white"> </h3> <h3 className="text-xl font-bold mb-6 text-white"> </h3>
<div className="flex flex-wrap gap-3 relative z-10"> <div className="flex flex-wrap gap-3 relative z-10">
{tags.length === 0 && <span className="text-sm text-brand-muted"> </span>} {targetKeywords.length === 0 && <span className="text-sm text-brand-muted"> </span>}
{tags.map((keyword, idx) => ( {targetKeywords.map((keyword: string, idx: number) => (
<span <span
key={idx} key={idx}
className="px-5 py-2.5 rounded-full bg-brand-card border border-brand-muted/30 text-white text-sm hover:border-brand-accent/50 transition-colors" className="px-5 py-2.5 rounded-full bg-brand-card border border-brand-muted/30 text-white text-sm hover:border-brand-accent/50 transition-colors"
@ -492,7 +617,11 @@ const AnalysisResultSection: React.FC<AnalysisResultSectionProps> = ({ onBack, o
</div> </div>
</div> </div>
<div className="fixed bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none"> {/* 콘텐츠 생성 버튼 */}
<div
className="fixed bottom-8 flex justify-center z-50 pointer-events-none"
style={{ left: buttonPosition.left, width: buttonPosition.width }}
>
<button <button
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"

View File

@ -100,14 +100,13 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = ({ onBack }) => {
await deleteVideo(deleteTargetId); await deleteVideo(deleteTargetId);
// 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영) // 삭제 성공 시 로컬 상태에서 즉시 제거 (UI 즉시 반영)
// fetchVideos()를 호출하지 않음 - 서버 캐시 또는 동기화 지연으로 인해
// 삭제된 항목이 다시 나타날 수 있기 때문
setVideos(prev => prev.filter(video => video.task_id !== deleteTargetId)); setVideos(prev => prev.filter(video => video.task_id !== deleteTargetId));
setTotal(prev => Math.max(0, prev - 1)); setTotal(prev => Math.max(0, prev - 1));
setDeleteModalOpen(false); setDeleteModalOpen(false);
setDeleteTargetId(null); setDeleteTargetId(null);
// 서버와 동기화를 위해 목록 새로고침
await fetchVideos();
} catch (err) { } catch (err) {
console.error('Delete failed:', err); console.error('Delete failed:', err);
alert('삭제에 실패했습니다.'); alert('삭제에 실패했습니다.');

View File

@ -1,3 +1,36 @@
// 타겟 페르소나
export interface TargetPersona {
persona: string;
age: {
min_age: number;
max_age: number;
};
favor_target: string[];
decision_trigger: string;
}
// 셀링 포인트
export interface SellingPoint {
category: string;
description: string;
score: number;
}
// 마케팅 분석 결과
export interface MarketingAnalysis {
brand_identity: {
location_feature_analysis: string;
concept_scalability: string;
};
market_positioning: {
category_definition: string;
core_value: string;
};
target_persona: TargetPersona[];
selling_points: SellingPoint[];
target_keywords: string[];
}
export interface CrawlingResponse { export interface CrawlingResponse {
image_list: string[]; image_list: string[];
image_count: number; image_count: number;
@ -6,11 +39,7 @@ export interface CrawlingResponse {
region: string; region: string;
detail_region_info: string; detail_region_info: string;
}; };
marketing_analysis: { marketing_analysis: MarketingAnalysis;
report: string;
tags: string[];
facilities: string[];
};
} }
// URL 이미지 (서버에서 가져온 이미지) // URL 이미지 (서버에서 가져온 이미지)