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 ( -

- {block.text} -

- ); - } - if (block.type === 'list') { - const ListTag = block.ordered ? 'ol' : 'ul'; - const listClass = block.ordered ? 'list-decimal' : 'list-disc'; - return ( - - {block.items.map((item, itemIdx) => ( -
  • {renderInlineMarkdown(item)}
  • - ))} -
    - ); - } - 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 }) => ( @@ -274,13 +37,6 @@ const UsersIcon = () => ( ); -const TrendingUpIcon = () => ( - - - - -); - const LayoutGridIcon = () => ( @@ -290,28 +46,388 @@ const LayoutGridIcon = () => ( ); +const ChartIcon = () => ( + + + +); + +// 범용 애니메이션 아이템 컴포넌트 +interface AnimatedItemProps { + children: React.ReactNode; + index: number; + baseDelay?: number; + className?: string; +} + +const AnimatedItem: React.FC = ({ 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 ( +
    + {children} +
    + ); +}; + +// 애니메이션 숫자 카운터 컴포넌트 +interface AnimatedScoreProps { + score: number; + duration?: number; + delay?: number; + className?: string; +} + +const AnimatedScore: React.FC = ({ 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 {displayScore}; +}; + +// 애니메이션 USP 아이템 컴포넌트 +interface AnimatedUSPItemProps { + usp: { category: string; description: string; score: number }; + index: number; + isTop?: boolean; + getScoreColor: (score: number) => string; +} + +const AnimatedUSPItem: React.FC = ({ 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 ( +
    +
    +
    +
    +
    + {usp.category} + TOP +
    +
    {usp.description}
    +
    + +
    +
    + ); + } + + return ( +
    +
    +
    {usp.category}
    +
    {usp.description}
    +
    + +
    + ); +}; + +// 레이더 차트 컴포넌트 +interface RadarChartProps { + data: { category: string; description: string; score: number }[]; + size?: number; +} + +const RadarChart: React.FC = ({ data, size = 280 }) => { + // 애니메이션을 위한 현재 점수 상태 (0에서 시작) + const [animatedScores, setAnimatedScores] = useState(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 ( +
    + + {/* 동심원 (레벨) */} + {levelPaths.map((path, i) => ( + + ))} + + {/* 축 라인 */} + {axisLines.map((path, i) => ( + + ))} + + {/* 데이터 영역 */} + + + {/* 데이터 포인트 */} + {dataPoints.map((point, i) => ( + + ))} + + {/* 라벨 (한글 설명 + 영어 카테고리) */} + {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 ( + + {/* 한글 설명 (메인 라벨) */} + + {item.description} + + {/* 영어 카테고리 (서브 라벨) */} + + {item.category} + + + ); + })} + +
    + ); +}; + const AnalysisResultSection: React.FC = ({ onBack, onGenerate, data }) => { const { processed_info, marketing_analysis } = data; - const tags = marketing_analysis?.tags || []; - const facilities = marketing_analysis?.facilities || []; - const reportSections = useMemo( - () => splitMarkdownSections(marketing_analysis?.report || ''), - [marketing_analysis?.report] - ); - const locationAnalysis = - pickSectionContent(reportSections, ['지역', '입지']) || reportSections[0]?.content || ''; - const conceptAnalysis = - pickSectionContent(reportSections, ['핵심', '차별', '컨셉', '콘셉트']) || reportSections[1]?.content || ''; - const targetSection = pickSectionContent(reportSections, ['타겟', '고객']); - const positioningCategory = facilities.length > 0 ? facilities.join(' · ') : '정보 없음'; - const positioningCore = - pickSectionContent(reportSections, ['핵심 가치', '핵심', '차별']) || tags.slice(0, 3).join(' · ') || '정보 없음'; - const usps: USP[] = useMemo(() => buildUSPs(facilities, tags), [facilities, tags]); - const topUSP = useMemo( - () => [...usps].sort((a, b) => b.score - a.score)[0], - [usps] - ); - const targets = useMemo(() => buildTargets(targetSection, tags), [targetSection, tags]); + const containerRef = useRef(null); + const [buttonPosition, setButtonPosition] = useState({ left: 0, width: 0 }); + + const brandIdentity = marketing_analysis?.brand_identity; + const marketPositioning = marketing_analysis?.market_positioning; + const targetPersonas = marketing_analysis?.target_persona || []; + const sellingPoints = marketing_analysis?.selling_points || []; + const targetKeywords = marketing_analysis?.target_keywords || []; + + // 컨테이너 기준으로 버튼 위치 계산 + useEffect(() => { + const updateButtonPosition = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setButtonPosition({ left: rect.left, width: rect.width }); + } + }; + + updateButtonPosition(); + window.addEventListener('resize', updateButtonPosition); + + // 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 (
    @@ -336,8 +452,10 @@ const AnalysisResultSection: React.FC = ({ onBack, o

    -
    +
    + {/* 왼쪽 컬럼 */}
    + {/* 브랜드 정체성 카드 */}
    @@ -356,131 +474,138 @@ const AnalysisResultSection: React.FC = ({ onBack, o
    -
    +

    입지 특성 분석

    - {locationAnalysis ? renderMarkdown(locationAnalysis) :

    정보 없음

    } -
    -
    +

    {brandIdentity?.location_feature_analysis || '정보 없음'}

    + +

    컨셉 확장성

    - {conceptAnalysis ? renderMarkdown(conceptAnalysis) :

    정보 없음

    } -
    +

    {brandIdentity?.concept_scalability || '정보 없음'}

    +
    + {/* 시장 포지셔닝 카드 */}

    시장 포지셔닝

    -
    - - 카테고리 정의 - -
    {renderMarkdown(positioningCategory)}
    -
    -
    - 핵심 가치 (Core Value) -
    {renderMarkdown(positioningCore)}
    -
    + +
    + 핵심 가치 (Core Value) +
    {marketPositioning?.core_value || '정보 없음'}
    +
    +
    + +
    + + 카테고리 정의 + +
    {marketPositioning?.category_definition || '정보 없음'}
    +
    +
    + {/* 타겟 페르소나 카드 */}

    타겟 페르소나

    - {targets.map((target, idx) => ( -
    -
    -
    - {renderInlineMarkdown(target.segment)} + {targetPersonas.length === 0 &&

    정보 없음

    } + {targetPersonas.map((persona: TargetPersona, idx: number) => ( + +
    +
    +
    + {persona.persona} +
    +
    + {persona.age.min_age}~{persona.age.max_age}세 +
    - {target.age &&
    {target.age}
    } -
    -
    - {target.needs.length > 0 && ( -
    - {target.needs.map((need, i) => ( + + {persona.favor_target.length > 0 && ( +
    + {persona.favor_target.map((favor: string, i: number) => ( - {renderInlineMarkdown(need)} + {favor} ))}
    )} - {target.triggers.length > 0 && ( + + {persona.decision_trigger && (

    - Trigger: {target.triggers.map((t: string, i: number) => {i > 0 && ', '}{renderInlineMarkdown(t)})} + Trigger: {persona.decision_trigger}

    )}
    -
    +
    ))}
    + {/* 오른쪽 컬럼 */}
    + {/* 주요 셀링 포인트 카드 */}
    -
    -

    주요 셀링 포인트 (USP)

    +
    +

    + 주요 셀링 포인트 (USP) +

    -
    - -
    - - {topUSP && ( -
    -
    -
    -
    - {topUSP.subLabel} - CORE -
    -
    {topUSP.label}
    -
    {topUSP.description}
    -
    -
    {topUSP.score}
    -
    + {/* 레이더 차트 */} + {sellingPoints.length > 0 && ( +
    +
    )} -
    - {usps - .filter((usp) => usp.label !== topUSP?.label) - .map((usp, idx) => ( -
    + )} + + {/* 나머지 USP 리스트 */} +
    + {sellingPoints.length === 0 &&

    정보 없음

    } + {sellingPoints + .filter((usp: SellingPoint) => usp.category !== topUSP?.category) + .map((usp: SellingPoint, idx: number) => ( + -
    -
    {usp.subLabel}
    -
    {usp.label}
    -
    {usp.description}
    -
    -
    {usp.score}
    -
    + usp={usp} + index={idx + 1} + isTop={false} + getScoreColor={getScoreColor} + /> ))}
    -
    + {/* 추천 타겟 키워드 */}

    추천 타겟 키워드

    - {tags.length === 0 && 정보 없음} - {tags.map((keyword, idx) => ( + {targetKeywords.length === 0 && 정보 없음} + {targetKeywords.map((keyword: string, idx: number) => ( = ({ onBack, o
    -
    + {/* 콘텐츠 생성 버튼 */} +