ado2 삭제 부분 수정, 브랜드 페이지 작업, 버그 fix .
parent
e997b2b5af
commit
cac825de19
|
|
@ -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": [
|
||||||
|
"군산숙소",
|
||||||
|
"군산감성숙소",
|
||||||
|
"전북숙소추천",
|
||||||
|
"군산여행",
|
||||||
|
"커플스테이",
|
||||||
|
"주말여행",
|
||||||
|
"감성스테이",
|
||||||
|
"조용한숙소",
|
||||||
|
"힐링스테이",
|
||||||
|
"스테이머뭄"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
38
src/App.tsx
38
src/App.tsx
|
|
@ -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 내부 필드 기본값 보장
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
|
<AnimatedItem index={0} baseDelay={200}>
|
||||||
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
|
<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>
|
||||||
</span>
|
<div className="font-semibold text-white">{marketPositioning?.core_value || '정보 없음'}</div>
|
||||||
<div className="font-bold text-lg text-white">{renderMarkdown(positioningCategory)}</div>
|
</div>
|
||||||
</div>
|
</AnimatedItem>
|
||||||
<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">
|
<AnimatedItem index={1} baseDelay={200}>
|
||||||
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
<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="font-semibold text-white">{renderMarkdown(positioningCore)}</div>
|
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">
|
||||||
</div>
|
카테고리 정의
|
||||||
|
</span>
|
||||||
|
<div className="font-bold text-lg text-white">{marketPositioning?.category_definition || '정보 없음'}</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 className="text-xs text-brand-muted">
|
||||||
|
{persona.age.min_age}~{persona.age.max_age}세
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{target.age && <div className="text-xs text-brand-muted">{target.age}</div>}
|
|
||||||
</div>
|
{persona.favor_target.length > 0 && (
|
||||||
<div className="flex-1">
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
{target.needs.length > 0 && (
|
{persona.favor_target.map((favor: string, i: number) => (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
||||||
{target.needs.map((need, i) => (
|
|
||||||
<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"
|
||||||
|
|
|
||||||
|
|
@ -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('삭제에 실패했습니다.');
|
||||||
|
|
|
||||||
|
|
@ -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 이미지 (서버에서 가져온 이미지)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue