diff --git a/analysisResult.json b/analysisResult.json
new file mode 100644
index 0000000..dde7532
--- /dev/null
+++ b/analysisResult.json
@@ -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": [
+ "군산숙소",
+ "군산감성숙소",
+ "전북숙소추천",
+ "군산여행",
+ "커플스테이",
+ "주말여행",
+ "감성스테이",
+ "조용한숙소",
+ "힐링스테이",
+ "스테이머뭄"
+ ]
+ }
+
+}
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 59e8224..7e12feb 100755
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -53,9 +53,17 @@ const App: React.FC = () => {
const data = JSON.parse(savedAnalysisData) as CrawlingResponse;
// 기본값 보장
if (data.marketing_analysis) {
- data.marketing_analysis.tags = data.marketing_analysis.tags || [];
- data.marketing_analysis.facilities = data.marketing_analysis.facilities || [];
- data.marketing_analysis.report = data.marketing_analysis.report || '';
+ data.marketing_analysis.brand_identity = data.marketing_analysis.brand_identity || {
+ location_feature_analysis: '',
+ 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) {
data.processed_info.customer_name = data.processed_info.customer_name || '알 수 없음';
@@ -197,14 +205,26 @@ const App: React.FC = () => {
if (!data.marketing_analysis) return false;
// marketing_analysis 내부 필드 기본값 보장
- if (!data.marketing_analysis.tags) {
- data.marketing_analysis.tags = [];
+ if (!data.marketing_analysis.brand_identity) {
+ data.marketing_analysis.brand_identity = {
+ location_feature_analysis: '',
+ concept_scalability: ''
+ };
}
- if (!data.marketing_analysis.facilities) {
- data.marketing_analysis.facilities = [];
+ if (!data.marketing_analysis.market_positioning) {
+ data.marketing_analysis.market_positioning = {
+ category_definition: '',
+ core_value: ''
+ };
}
- if (!data.marketing_analysis.report) {
- data.marketing_analysis.report = '';
+ if (!data.marketing_analysis.target_persona) {
+ 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 내부 필드 기본값 보장
diff --git a/src/pages/Analysis/AnalysisResultSection.tsx b/src/pages/Analysis/AnalysisResultSection.tsx
index 3444647..39a8c7a 100755
--- a/src/pages/Analysis/AnalysisResultSection.tsx
+++ b/src/pages/Analysis/AnalysisResultSection.tsx
@@ -1,7 +1,6 @@
-import React, { useMemo } from 'react';
-import { CrawlingResponse } from '../../types/api';
-import GeometricChart, { USP } from './GeometricChart';
+import React, { useState, useEffect, useRef } from 'react';
+import { CrawlingResponse, TargetPersona, SellingPoint } from '../../types/api';
interface AnalysisResultSectionProps {
onBack: () => void;
@@ -9,242 +8,6 @@ interface AnalysisResultSectionProps {
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 (
-
- {part.slice(2, -2)}
-
- );
- }
- if (part.startsWith('`') && part.endsWith('`')) {
- return (
-
- {part.slice(1, -1)}
-
- );
- }
- if (part.startsWith('#')) {
- return (
-
- {part}
-
- );
- }
- return {part};
- });
-};
-
-const renderMarkdown = (text: string) => {
- const blocks = parseMarkdownBlocks(text);
-
- return blocks.map((block, idx) => {
- if (block.type === 'heading') {
- return (
-
- {renderInlineMarkdown(block.text)} -
- ); - }); -}; - -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 TrendingUpIcon = () => ( - -); - const LayoutGridIcon = () => ( ); +const ChartIcon = () => ( + +); + +// 범용 애니메이션 아이템 컴포넌트 +interface AnimatedItemProps { + children: React.ReactNode; + index: number; + baseDelay?: number; + className?: string; +} + +const AnimatedItem: React.FC정보 없음
} -{brandIdentity?.location_feature_analysis || '정보 없음'}
+ +정보 없음
} -{brandIdentity?.concept_scalability || '정보 없음'}
+정보 없음
} + {targetPersonas.map((persona: TargetPersona, idx: number) => ( +- Trigger: {target.triggers.map((t: string, i: number) => {i > 0 && ', '}{renderInlineMarkdown(t)})} + Trigger: {persona.decision_trigger}
)}정보 없음
} + {sellingPoints + .filter((usp: SellingPoint) => usp.category !== topUSP?.category) + .map((usp: SellingPoint, idx: number) => ( +