commit 535daa36255e5f3dddb3666fee7bd13e67a81570 Author: Haewon Kam Date: Fri Apr 17 10:38:42 2026 +0900 chore: initial scaffolding — Vite + React 19 + Tailwind 4 - 5 pages: Landing, AnalysisStart (MVP 핵심 — 채널 핸들 직접 입력 폼), AnalysisLoading (상태 폴링), Report, MarketingPlan - useAnalysis (POST + 2초 폴링) + useReport (API-only, DEMO_REPORTS 제거) - 23 report components + types + transform utils (프로토타입에서 이식) - Tailwind 4 @theme: Pretendard + brand palette (primary-900, accent) - axios apiClient with X-API-Key interceptor - Vite proxy /api → localhost:8000 (백엔드 연동) Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3f9cc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# ======================================================== +# infinith-web — 프런트엔드 환경변수 +# Copy to .env.development (local) / .env.production (빌드) +# ======================================================== + +# 백엔드 API base URL +# - dev: 공백 (Vite proxy가 /api → localhost:8000 포워딩) +# - prod: https://infinith-api.up.railway.app 등 배포 URL +VITE_API_BASE_URL= + +# MVP 인증 — infinith-api .env 의 API_KEY 와 동일값 +VITE_API_KEY=change-me-dev-only diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74f5118 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build +dist/ +dist-ssr/ +build/ +*.local + +# Env +.env +.env.* +!.env.example + +# Editor +.vscode/* +!.vscode/extensions.json +.idea/ +*.swp + +# OS +.DS_Store +Thumbs.db + +# Generated types (re-generate via `npm run gen-types`) +src/api-types/schema.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..e09ada7 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# infinith-web + +INFINITH 실서비스 프런트엔드. React 19 + Vite 6 + TypeScript + Tailwind 4. + +## Quick start + +```bash +# 1. 의존성 +npm install + +# 2. 환경변수 +cp .env.example .env.development +# 기본값으로도 동작 (Vite proxy + API_KEY=change-me-dev-only) + +# 3. 백엔드 기동 확인 (별도 터미널) +# cd ../infinith-api && docker compose up -d +# curl http://localhost:8000/health + +# 4. 프런트 dev 서버 +npm run dev +# → http://localhost:5173 +``` + +## Scripts + +| 명령 | 설명 | +|---|---| +| `npm run dev` | Vite 개발 서버 (port 5173, `/api/*` 프록시 → :8000) | +| `npm run build` | 프로덕션 번들 (`dist/`) | +| `npm run preview` | 빌드 결과 로컬 확인 | +| `npm run lint` | `tsc --noEmit` (타입 체크) | +| `npm run gen-types` | 백엔드 OpenAPI → `src/api-types/schema.d.ts` 재생성 | + +## 구조 + +``` +src/ +├─ main.tsx # React entry + BrowserRouter +├─ App.tsx # 라우트 정의 +├─ index.css # Tailwind 4 @theme — brand 색상 토큰 +├─ pages/ # Landing / AnalysisStart / AnalysisLoading / Report / Plan +├─ components/report/ # 23개 리포트 컴포넌트 (프로토타입에서 복사) +├─ hooks/ +│ ├─ useAnalysis.ts # POST /analyses + 상태 폴링 +│ └─ useReport.ts # GET /reports/{id} — DEMO_REPORTS 하드코딩 제거 +├─ lib/ +│ ├─ apiClient.ts # axios + X-API-Key 헤더 +│ ├─ transformReport.ts # 프로토타입 이식 +│ └─ transformPlan.ts +├─ types/ # report.ts, plan.ts, studio.ts (프로토타입 이식) +└─ api-types/ # openapi-typescript 자동 생성 (gitignored) +``` + +## 타입 동기화 (백엔드 ↔ 프런트) + +계약 변경 시: + +1. **백엔드:** `app/schemas/*.py` 수정 + `poetry run uvicorn app.main:app` 실행 +2. **프런트:** `npm run gen-types` — `src/api-types/schema.d.ts` 재생성 +3. 컴포넌트에서 `import type { components } from '@/api-types/schema'` 로 사용 + +MVP 기간에는 `useAnalysis.ts` 등에 로컬 인터페이스도 유지 (참조용). 계약 안정화 시 제거. + +## 관련 문서 + +- [../infinith-api/docs/API_CONTRACT.md](../infinith-api/docs/API_CONTRACT.md) — 백엔드 계약 단일 진실 소스 +- [../infinith-api/docs/ONBOARDING.md](../infinith-api/docs/ONBOARDING.md) — 백엔드 온보딩 + +## 프로토타입과 분리 원칙 + +- 프로토타입 `INFINITH/` 레포는 **읽기 전용 참조**. 수정 금지 (영업 데모/광고 촬영용 유지) +- 공유하는 것: `types/*.ts`, `lib/transform*.ts`, `components/report/*` — 파일 복사로 이식 +- `useReport.ts` 의 `DEMO_REPORTS` 매핑은 **MVP 에서 제거** — API-only + +## 배포 (Vercel) + +```bash +# 최초 1회 +vercel link +vercel env add VITE_API_BASE_URL # production URL +vercel env add VITE_API_KEY + +# 배포 +npm run build +vercel --prod +``` diff --git a/docs/PORTING_FROM_PROTOTYPE.md b/docs/PORTING_FROM_PROTOTYPE.md new file mode 100644 index 0000000..ac5c0ff --- /dev/null +++ b/docs/PORTING_FROM_PROTOTYPE.md @@ -0,0 +1,77 @@ +# PORTING_FROM_PROTOTYPE + +프로토타입 [`INFINITH/`](../../INFINITH/) → `infinith-web` 이식 현황 + 추가 작업 가이드. + +## 이미 이식된 파일 + +✅ **Types** (그대로 복사): +- `src/types/report.ts` +- `src/types/plan.ts` +- `src/types/studio.ts` + +✅ **Lib utilities** (그대로 복사): +- `src/lib/transformReport.ts` +- `src/lib/transformPlan.ts` + +✅ **Report components** (23개 파일, 그대로 복사): +- `src/components/report/ChannelOverview.tsx` +- `src/components/report/ClinicSnapshot.tsx` +- `src/components/report/FacebookAudit.tsx` +- `src/components/report/InstagramAudit.tsx` +- `src/components/report/KPIDashboard.tsx` +- `src/components/report/OtherChannels.tsx` +- `src/components/report/ProblemDiagnosis.tsx` +- `src/components/report/ReportHeader.tsx` +- `src/components/report/ReportNav.tsx` +- `src/components/report/RoadmapTimeline.tsx` +- `src/components/report/TransformationProposal.tsx` +- `src/components/report/YouTubeAudit.tsx` +- `src/components/report/sections/*` +- `src/components/report/ui/*` + +## 아직 이식 필요 — import 에러 가능성 체크 + +이식한 컴포넌트들이 프로토타입 특정 경로에 의존하면 첫 빌드 때 에러 발생: + +### Checklist (D1 프런트 담당) + +- [ ] `npm install` → `npm run dev` → 브라우저 404 대신 500 나오면 에러 메시지 확인 +- [ ] 모든 `import '@/...'` 경로가 vite alias 와 일치하는지 검증 (`vite.config.ts` 설정됨) +- [ ] 프로토타입에서만 쓰던 헬퍼가 있으면 이 목록에 추가하고 같이 복사: + - `src/lib/normalizeHandles.ts` (있다면) + - `src/lib/calendarExport.ts` (Plan 페이지에서만 필요) + - `src/hooks/useExportPDF.ts` (PDF 내보내기 — 영업 데모에서만 필요시) +- [ ] Tailwind 4 신택스 — 프로토타입의 `@theme` 값은 `index.css` 에 이미 이식됨 +- [ ] 폰트 (Pretendard) — `index.html` 에 CDN 링크 삽입 완료 + +## 이식 하지 않은 것 — 의도적 제외 + +- ❌ `src/data/mockReport_*.ts` — MVP 는 API-only. 하드코딩 mock 제거 +- ❌ `src/hooks/useReport.ts` 의 `DEMO_REPORTS` 매핑 — API-only 로 재작성됨 +- ❌ 스튜디오 페이지 (`src/pages/ContentStudio*.tsx`) — post-MVP +- ❌ 채널 연결 페이지 — post-MVP +- ❌ 성과/배포 페이지 — post-MVP + +## 후속 작업 (D5 이후) + +- [ ] `ReportPage.tsx` 에 이식한 report 컴포넌트들을 실제 조합 (현재 JSON pre 덤프) +- [ ] `MarketingPlanPage.tsx` 구현 (`useMarketingPlan` 훅 + `transformPlan` 활용) +- [ ] `ScreenshotContext` 가 필요하면 추가 이식 +- [ ] PDF 내보내기 필요시 `useExportPDF` 이식 + +## 재복사 스크립트 + +필요시 프로토타입 업데이트 pull-in 용: + +```bash +# 프로토타입 루트에서 +PROTO=/Users/haewonkam/Claude/Agentic\ Marketing/INFINITH +WEB=/Users/haewonkam/Claude/Agentic\ Marketing/infinith-web + +cp "$PROTO/src/types/"*.ts "$WEB/src/types/" +cp "$PROTO/src/lib/transformReport.ts" "$WEB/src/lib/" +cp "$PROTO/src/lib/transformPlan.ts" "$WEB/src/lib/" +cp -R "$PROTO/src/components/report/." "$WEB/src/components/report/" +``` + +**주의:** 프로토타입과 MVP 가 분기한 이후로는 덮어쓰기 금지. `git diff` 확인 후 선별 병합. diff --git a/index.html b/index.html new file mode 100644 index 0000000..bad3aa4 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + INFINITH — AI 마케팅 분석 + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b70d394 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "infinith-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc --noEmit", + "gen-types": "openapi-typescript http://localhost:8000/openapi.json -o src/api-types/schema.d.ts" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "axios": "^1.7.7", + "motion": "^11.11.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "tailwindcss": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "openapi-typescript": "^7.4.0" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..4aadf88 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,20 @@ +import { Route, Routes } from 'react-router-dom' +import LandingPage from './pages/LandingPage' +import AnalysisStartPage from './pages/AnalysisStartPage' +import AnalysisLoadingPage from './pages/AnalysisLoadingPage' +import ReportPage from './pages/ReportPage' +import MarketingPlanPage from './pages/MarketingPlanPage' +import NotFoundPage from './pages/NotFoundPage' + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} diff --git a/src/components/report/ChannelOverview.tsx b/src/components/report/ChannelOverview.tsx new file mode 100644 index 0000000..88ddbc8 --- /dev/null +++ b/src/components/report/ChannelOverview.tsx @@ -0,0 +1,76 @@ +import type { ComponentType } from 'react'; +import { motion } from 'motion/react'; +import { Youtube, Instagram, Globe, Star, Facebook, Search } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { ScoreRing } from './ui/ScoreRing'; +import { SeverityBadge } from './ui/SeverityBadge'; +import type { ChannelScore } from '../../types/report'; + +interface ChannelOverviewProps { + channels: ChannelScore[]; +} + +const iconMap: Record> = { + youtube: Youtube, + instagram: Instagram, + facebook: Facebook, + star: Star, + globe: Globe, + search: Search, +}; + +const channelLabel: Record = { + naverBlog: '네이버 블로그', + naverPlace: '네이버 플레이스', + gangnamUnni: '강남언니', + instagram: 'Instagram', + youtube: 'YouTube', + facebook: 'Facebook', + website: '웹사이트', + tiktok: 'TikTok', + blog: '블로그', +}; + +const brandColor: Record = { + facebook: '#1877F2', + instagram: '#E1306C', + youtube: '#FF0000', + globe: '#6B2D8B', +}; + +function getChannelColor(icon: string | undefined): string | undefined { + return brandColor[icon?.toLowerCase() ?? '']; +} + +export default function ChannelOverview({ channels }: ChannelOverviewProps) { + return ( + +
+ {channels.map((ch, i) => { + const Icon = iconMap[ch.icon?.toLowerCase()] ?? Globe; + const color = getChannelColor(ch.icon); + return ( + +
+ +
+

{channelLabel[ch.channel] || ch.channel}

+ +

+ {ch.headline} +

+ +
+ ); + })} +
+
+ ); +} diff --git a/src/components/report/ClinicSnapshot.tsx b/src/components/report/ClinicSnapshot.tsx new file mode 100644 index 0000000..54690a8 --- /dev/null +++ b/src/components/report/ClinicSnapshot.tsx @@ -0,0 +1,184 @@ +import { motion } from 'motion/react'; +import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, Building2 } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import type { ClinicSnapshot as ClinicSnapshotType } from '../../types/report'; + +interface ClinicSnapshotProps { + data: ClinicSnapshotType; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +interface InfoField { + label: string; + value: string; + icon: typeof Calendar; + href?: string; +} + +const infoFields = (data: ClinicSnapshotType): InfoField[] => [ + data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null, + data.staffCount > 0 ? { label: '전문의', value: `${data.staffCount}명`, icon: Users } : null, + data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null, + data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null, + data.registryData?.district ? { label: '지역구', value: data.registryData.district, icon: Building2 } : null, + data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null, + data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null, + data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null, + data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null, +].filter(Boolean) as InfoField[]; + +export default function ClinicSnapshot({ data }: ClinicSnapshotProps) { + const fields = infoFields(data); + const isVerified = data.source === 'registry'; + + return ( + + {/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */} + + {/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */} + {isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && ( + + {data.registryData?.gangnamUnniUrl && ( + + 강남언니 + + )} + {data.registryData?.naverPlaceUrl && ( + + 네이버 플레이스 + + )} + {data.registryData?.googleMapsUrl && ( + + Google Maps + + )} + + )} + +
+ {fields.map((field, i) => { + const Icon = field.icon; + return ( + +
+
+ +
+
+

{field.label}

+ {field.href ? ( + + {field.value} + {!field.href.startsWith('tel:') && } + + ) : ( +

{field.value}

+ )} +
+
+
+ ); + })} +
+ + {/* Lead Doctor Highlight — only show if doctor name exists */} + {data.leadDoctor.name && ( + +
+ +

대표 원장

+
+

{data.leadDoctor.name}

+ {data.leadDoctor.credentials && ( +

{data.leadDoctor.credentials}

+ )} + {data.leadDoctor.rating > 0 && ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + {data.leadDoctor.rating} + +
+ {data.leadDoctor.reviewCount > 0 && ( + + 리뷰 {formatNumber(data.leadDoctor.reviewCount)}건 + + )} +
+ )} +
+ )} + + {/* Certifications */} + {data.certifications.length > 0 && ( + +

인증 및 자격

+
+ {data.certifications.map((cert) => ( + + {cert} + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/report/FacebookAudit.tsx b/src/components/report/FacebookAudit.tsx new file mode 100644 index 0000000..ce369a2 --- /dev/null +++ b/src/components/report/FacebookAudit.tsx @@ -0,0 +1,364 @@ +import { useState } from 'react'; +import { motion } from 'motion/react'; +import { + Facebook, AlertCircle, AlertTriangle, ExternalLink, CheckCircle2, XCircle, + ArrowRight, Link2, MessageCircle, TrendingUp, Eye, ImageIcon, Globe, +} from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { SeverityBadge } from './ui/SeverityBadge'; +import { EvidenceGallery } from './ui/EvidenceGallery'; +import type { + FacebookAudit as FacebookAuditType, + FacebookPage, + BrandInconsistency, + DiagnosisItem, +} from '../../types/report'; + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +/* ─── Page Card ─── */ +function PageCard({ page, index }: { key?: string | number; page: FacebookPage; index: number }) { + const isKR = page.language === 'KR'; + const langColor = isKR ? 'bg-slate-100 text-slate-700' : 'bg-[#EFF0FF] text-[#3A3F7C]'; + const isLogoMismatch = page.logo?.includes('불일치'); + const isLowFollowers = page.followers < 500; + + return ( + + {/* Header */} +
+
+ + {page.label} + +
+ +
+
+ {isKR && isLowFollowers && ( + + 방치 상태 + + )} + {page.hasWhatsApp && ( + + WhatsApp 연결 + + )} +
+ +

+ {page.url ? ( + + {page.pageName} + + + ) : page.pageName} +

+

{page.category}

+ + {/* Metrics grid */} +
+
+

팔로워

+

+ {formatNumber(page.followers)} +

+
+
+

리뷰

+

+ {page.reviews} +

+
+
+

팔로잉

+

{formatNumber(page.following)}

+
+
+ + {/* Detail rows */} +
+
+ 최근 게시물 + {page.recentPostAge} +
+ {page.postFrequency && ( +
+ 게시 빈도 + {page.postFrequency} +
+ )} + {page.topContentType && ( +
+ 콘텐츠 유형 + {page.topContentType} +
+ )} + {page.engagement && ( +
+ 참여율 + {page.engagement} +
+ )} +
+ + {/* Logo analysis - the enhanced version */} +
+
+ {isLogoMismatch + ? + : + } +

+ 로고 {page.logo} +

+
+

+ {page.logoDescription} +

+
+ + {/* Domain link */} +
+
+ +

연결 도메인

+
+

+ {page.linkedDomain || page.link} +

+
+ + {/* Bio */} +
+

Bio

+

"{page.bio}"

+
+ + {/* Link moved to page name header */} +
+ ); +} + +/* ─── Brand Inconsistency Map ─── */ +function BrandConsistencyMap({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) { + const [expanded, setExpanded] = useState(0); + + return ( + +

Brand Consistency Map

+

전 채널 브랜드 일관성 분석

+ +
+ {inconsistencies.map((item, i) => ( +
+ {/* Header - clickable */} + + + {/* Expanded content */} + {expanded === i && ( +
+ {/* Channel values */} +
+ {item.values.map((v) => ( +
+ {v.channel} + + {v.value} + + + {v.isCorrect + ? + : + } + +
+ ))} +
+ + {/* Impact */} +
+

+ + Impact +

+

{item.impact}

+
+ + {/* Recommendation */} +
+

+ + Recommendation +

+

{item.recommendation}

+
+
+ )} +
+ ))} +
+
+ ); +} + +/* ─── Diagnosis Section ─── */ +function DiagnosisSection({ items }: { items: DiagnosisItem[] }) { + return ( + +

진단 결과

+

Facebook 채널 문제점

+ +
+ {items.map((item, i) => ( +
+
+
+

{item.category}

+
+

{item.detail}

+
+ +
+
+ {item.evidenceIds && item.evidenceIds.length > 0 && ( + + )} +
+ ))} +
+
+ ); +} + +/* ─── Consolidation Recommendation ─── */ +function ConsolidationCard({ text }: { text: string }) { + return ( + +
+
+ +
+
+

통합 권장 사항

+

{text}

+
+
+
+ ); +} + +/* ─── Main Component ─── */ +export default function FacebookAudit({ data }: { data: FacebookAuditType }) { + const hasData = data.pages.length > 0 || data.diagnosis.length > 0; + + if (!hasData) return null; + + return ( + + {/* Page cards side by side */} +
+ {data.pages.map((page, i) => ( + + ))} +
+ + {/* Brand Consistency Map - the new enhanced section */} + {data.brandInconsistencies && data.brandInconsistencies.length > 0 && ( + + )} + + {/* Diagnosis table */} + {data.diagnosis && data.diagnosis.length > 0 && ( + + )} + + {/* Consolidation recommendation */} + {data.consolidationRecommendation && ( + + )} +
+ ); +} diff --git a/src/components/report/InstagramAudit.tsx b/src/components/report/InstagramAudit.tsx new file mode 100644 index 0000000..2840ebc --- /dev/null +++ b/src/components/report/InstagramAudit.tsx @@ -0,0 +1,158 @@ +import { motion } from 'motion/react'; +import { Instagram, AlertCircle, FileText, Users, Eye, ExternalLink } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { EmptyState } from './ui/EmptyState'; +import { MetricCard } from './ui/MetricCard'; +import { DiagnosisRow } from './ui/DiagnosisRow'; +import type { InstagramAudit as InstagramAuditType, InstagramAccount } from '../../types/report'; + +interface InstagramAuditProps { + data: InstagramAuditType; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +function AccountCard({ account, index }: { key?: string | number; account: InstagramAccount; index: number }) { + const langColor = account.language === 'KR' + ? 'bg-slate-100 text-slate-700' + : 'bg-[#EFF0FF] text-[#3A3F7C]'; + + return ( + + {/* Language badge + handle */} +
+ + {account.label} + +
+ +
+
+ +

+ {account.handle ? ( + + {account.handle} + + + ) : account.handle} +

+

{account.category}

+ + {/* Compact metrics */} +
+
+

게시물

+

{formatNumber(account.posts)}

+
+
+

팔로워

+

{formatNumber(account.followers)}

+
+
+

팔로잉

+

{formatNumber(account.following)}

+
+
+ + {/* Format & reels */} +
+
+ 콘텐츠 포맷 + {account.contentFormat} +
+
+ 릴스 수 + + {account.reelsCount === 0 ? ( + + + 0 (미운영) + + ) : ( + account.reelsCount + )} + +
+
+ + {/* Highlights */} + {account.highlights.length > 0 && ( +
+

하이라이트

+
+ {account.highlights.map((h) => ( + + {h} + + ))} +
+
+ )} + + {/* Bio */} + {account.bio && ( +
+

Bio

+

{account.bio}

+
+ )} +
+ ); +} + +export default function InstagramAudit({ data }: InstagramAuditProps) { + const hasAccounts = data.accounts.length > 0 && data.accounts.some(a => a.handle || a.followers > 0); + + return ( + + {!hasAccounts && data.diagnosis.length === 0 && ( + + )} + + {/* Account cards */} + {hasAccounts && ( +
+ {data.accounts.map((account, i) => ( + + ))} +
+ )} + + {/* Diagnosis */} + {data.diagnosis.length > 0 && ( + +

진단 결과

+ {data.diagnosis.map((item, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/report/KPIDashboard.tsx b/src/components/report/KPIDashboard.tsx new file mode 100644 index 0000000..070c646 --- /dev/null +++ b/src/components/report/KPIDashboard.tsx @@ -0,0 +1,128 @@ +import { motion } from 'motion/react'; +import { useParams, useNavigate } from 'react-router'; +import { TrendingUp, ArrowUpRight, Download, Loader2 } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { useExportPDF } from '../../hooks/useExportPDF'; +import type { KPIMetric } from '../../types/report'; + +interface KPIDashboardProps { + metrics: KPIMetric[]; + clinicName?: string; +} + +function isNegativeValue(value: string | number): boolean { + const lower = String(value).toLowerCase(); + return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a' || lower === '-' || lower.includes('측정 불가'); +} + +/** Format large numbers for readability: 150000 → 150K, 1500000 → 1.5M */ +function formatKpiValue(value: string | number): string { + const str = String(value ?? ''); + // If already formatted (contains K, M, %, ~, 월, 건, etc.) return as-is + if (/[KkMm%~월건회개명/]/.test(str)) return str; + // Try to parse as pure number + const num = parseInt(str.replace(/,/g, ''), 10); + if (isNaN(num)) return str; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`; + if (num >= 10_000) return `${Math.round(num / 1_000)}K`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return str; +} + +export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { exportPDF, isExporting } = useExportPDF(); + + return ( + + {/* KPI Table */} + + {/* Header */} +
+
Metric
+
Current
+
3-Month Target
+
12-Month Target
+
+ + {/* Data rows */} + {metrics.map((metric, i) => ( + +
{metric.metric}
+
+ {formatKpiValue(metric.current)} +
+
{formatKpiValue(metric.target3Month)}
+
{formatKpiValue(metric.target12Month)}
+
+ ))} +
+ + {/* CTA Card */} + +
+
+ +
+

+ Start Your Transformation +

+

+ INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다. +

+
+ + +
+
+
+
+ ); +} diff --git a/src/components/report/OtherChannels.tsx b/src/components/report/OtherChannels.tsx new file mode 100644 index 0000000..0a725f3 --- /dev/null +++ b/src/components/report/OtherChannels.tsx @@ -0,0 +1,157 @@ +import { motion } from 'motion/react'; +import { CheckCircle2, XCircle, HelpCircle, ExternalLink, AlertCircle, Globe, Shield } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import type { OtherChannel, WebsiteAudit } from '../../types/report'; + +interface OtherChannelsProps { + channels: OtherChannel[]; + website: WebsiteAudit; +} + +const statusConfig = { + active: { icon: CheckCircle2, color: 'text-[#9B8AD4]', label: '활성' }, + inactive: { icon: XCircle, color: 'text-[#D4889A]', label: '비활성' }, + unknown: { icon: HelpCircle, color: 'text-slate-400', label: '미확인' }, + not_found: { icon: XCircle, color: 'text-[#D4889A]', label: '미발견' }, +}; + +export default function OtherChannels({ channels, website }: OtherChannelsProps) { + return ( + + {/* Other Channels */} + +
+

기타 채널 현황

+
+
+ {channels.map((ch, i) => { + const cfg = statusConfig[ch.status]; + const StatusIcon = cfg.icon; + return ( + + +
+ {ch.url ? ( + + {ch.name} + + + ) : ( +

{ch.name}

+ )} +

{ch.details}

+
+ {cfg.label} +
+ ); + })} +
+
+ + {/* Website Tech Audit */} + +
+ +

웹사이트 기술 진단

+
+ + {/* Domain info */} +
+

기본 도메인: {website.primaryDomain}

+ {website.additionalDomains.length > 0 && ( +
+ {website.additionalDomains.map((d) => ( +

+ {d.domain} — {d.purpose} +

+ ))} +
+ )} +

+ 주요 CTA: {website.mainCTA} +

+
+ + {/* Tracking Pixels */} +
+

트래킹 픽셀 설치 현황

+
+ {website.trackingPixels.map((pixel) => ( +
+ {pixel.installed ? ( + + ) : ( + + )} +
+

+ {pixel.name} +

+ {pixel.details && ( +

{pixel.details}

+ )} +
+
+ ))} +
+
+ + {/* SNS Links Status */} + {!website.snsLinksOnSite && ( + + +
+

홈페이지에 SNS 링크 없음

+

+ 웹사이트에서 소셜 미디어 채널로의 연결이 없습니다. 방문자가 SNS를 통해 브랜드와 연결할 수 없습니다. +

+
+
+ )} + {website.snsLinksOnSite && ( +
+ +

홈페이지에 SNS 링크 연결됨

+
+ )} +
+
+ ); +} diff --git a/src/components/report/ProblemDiagnosis.tsx b/src/components/report/ProblemDiagnosis.tsx new file mode 100644 index 0000000..b6e443b --- /dev/null +++ b/src/components/report/ProblemDiagnosis.tsx @@ -0,0 +1,179 @@ +import { motion } from 'motion/react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { ShieldFilled, FileTextFilled, LinkExternalFilled } from '../icons/FilledIcons'; +import type { DiagnosisItem } from '../../types/report'; + +interface ProblemDiagnosisProps { + diagnosis: DiagnosisItem[]; +} + +/** + * Group individual diagnosis items into 3 core problem clusters. + * The AI may produce many fine-grained items — we cluster them + * into the key strategic buckets that the reference design shows. + */ +function clusterDiagnosis(items: DiagnosisItem[]): { + icon: typeof ShieldFilled; + title: string; + detail: string; +}[] { + // Categorise items into 3 strategic buckets + const brandItems: string[] = []; + const contentItems: string[] = []; + const funnelItems: string[] = []; + + for (const item of items) { + const cat = String(item.category ?? '').toLowerCase(); + const det = String(item.detail ?? '').toLowerCase(); + + // Brand identity / consistency issues + if ( + cat.includes('brand') || cat.includes('로고') || + det.includes('로고') || det.includes('프로필') || det.includes('아이덴티티') || + det.includes('일관') || det.includes('통일') || det.includes('브랜드') || + det.includes('비주얼') || det.includes('identity') || det.includes('brand') + ) { + brandItems.push(item.detail); + } + // Content / strategy issues + else if ( + det.includes('콘텐츠') || det.includes('업로드') || det.includes('포스팅') || + det.includes('릴스') || det.includes('shorts') || det.includes('영상') || + det.includes('게시물') || det.includes('전략') || det.includes('content') || + det.includes('카드뉴스') || det.includes('크로스') || det.includes('캘린더') || + cat.includes('youtube') || cat.includes('instagram') || cat.includes('콘텐츠') + ) { + contentItems.push(item.detail); + } + // Funnel / cross-platform / conversion issues + else { + funnelItems.push(item.detail); + } + } + + // If items didn't distribute, balance them + if (brandItems.length === 0 && items.length > 0) { + brandItems.push(items[0]?.detail || '채널 간 브랜드 비주얼이 통일되지 않았습니다.'); + } + if (contentItems.length === 0 && items.length > 1) { + contentItems.push(items[1]?.detail || '콘텐츠 전략 수립이 필요합니다.'); + } + if (funnelItems.length === 0 && items.length > 2) { + funnelItems.push(items[2]?.detail || '플랫폼 간 유입 전환이 단절되어 있습니다.'); + } + + return [ + { + icon: ShieldFilled, + title: '브랜드 아이덴티티 파편화', + detail: + brandItems.length > 0 + ? brandItems.slice(0, 3).join(' — ') + : '공식 검증 로고/타이포+골드는 Facebook KR에 웹사이트에만 적용, YouTube/Instagram에서 각각 다른 로고 사용 — 채널별로 4종 이상 다른 시각 아이덴티티가 사용됩니다.', + }, + { + icon: FileTextFilled, + title: '콘텐츠 전략 부재', + detail: + contentItems.length > 0 + ? contentItems.slice(0, 3).join(' — ') + : '콘텐츠 캘린더가 없음, 콘텐츠 기/가이드 없음, KR+EN 시장 타겟 전략 없음. YouTube→Instagram 크로스 포스팅 부재.', + }, + { + icon: LinkExternalFilled, + title: '플랫폼 간 유입 단절', + detail: + funnelItems.length > 0 + ? funnelItems.slice(0, 3).join(' — ') + : 'YouTube 10만+ → Instagram 1.4K 전환 실패, 웹사이트에서 SNS 유입 3% 미만, 상담전환 동선 부재.', + }, + ]; +} + +export default function ProblemDiagnosis({ diagnosis }: ProblemDiagnosisProps) { + if (!diagnosis || !Array.isArray(diagnosis) || diagnosis.length === 0) { + return null; + } + const clusters = clusterDiagnosis(diagnosis); + + return ( + + {/* Core 3 problem cards — large, prominent */} +
+ {clusters.map((cluster, i) => { + const Icon = cluster.icon; + // First card spans full width on md if 3 items + const isWide = i === 2; + return ( + + {/* Glow accent */} +
+ +
+
+ +
+
+

{cluster.title}

+

{cluster.detail}

+
+
+ + {/* Severity indicator dot */} +
+ +
+ + ); + })} +
+ + {/* Detailed diagnosis items — compact list below */} + {diagnosis.length > 3 && ( + +
+

+ 세부 진단 항목 ({diagnosis.length}건) +

+
+
+ {diagnosis.map((item, i) => ( +
+ +
+ {item.category} + {item.detail} +
+
+ ))} +
+
+ )} + + ); +} diff --git a/src/components/report/ReportHeader.tsx b/src/components/report/ReportHeader.tsx new file mode 100644 index 0000000..ea65783 --- /dev/null +++ b/src/components/report/ReportHeader.tsx @@ -0,0 +1,155 @@ +import { motion } from 'motion/react'; +import { Calendar, Globe, MapPin } from 'lucide-react'; +import { ScoreRing } from './ui/ScoreRing'; + +function formatDate(raw: string): string { + try { + return new Date(raw).toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return raw; + } +} + +interface ReportHeaderProps { + clinicName: string; + clinicNameEn: string; + overallScore: number; + date: string; + targetUrl: string; + location: string; + logoImage?: string; + brandColors?: { primary: string; accent: string; text: string }; +} + +export default function ReportHeader({ + clinicName, + logoImage, + brandColors, + clinicNameEn, + overallScore, + date, + targetUrl, + location, +}: ReportHeaderProps) { + return ( +
+ {/* Animated blobs */} + + + + +
+
+ {/* Left: Text content */} + + + Marketing Intelligence Report + + + {logoImage && ( + + {clinicName} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + + )} + + + {clinicName} + + + + {clinicNameEn} + + + + + + {formatDate(date)} + + + + {targetUrl} + + + + {location} + + + + + {/* Right: Score ring */} + +
+

+ Overall Score +

+ +
+
+
+
+
+ ); +} diff --git a/src/components/report/ReportNav.tsx b/src/components/report/ReportNav.tsx new file mode 100644 index 0000000..7dfdc57 --- /dev/null +++ b/src/components/report/ReportNav.tsx @@ -0,0 +1,81 @@ +import { useEffect, useRef, useState } from 'react'; + +interface ReportNavProps { + sections: { id: string; label: string }[]; +} + +export function ReportNav({ sections }: ReportNavProps) { + const [activeId, setActiveId] = useState(sections[0]?.id ?? ''); + const navRef = useRef(null); + const tabRefs = useRef>(new Map()); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); + + if (visible.length > 0) { + setActiveId(visible[0].target.id); + } + }, + { rootMargin: '-100px 0px -60% 0px', threshold: 0 } + ); + + sections.forEach(({ id }) => { + const el = document.getElementById(id); + if (el) observer.observe(el); + }); + + return () => observer.disconnect(); + }, [sections]); + + useEffect(() => { + const activeTab = tabRefs.current.get(activeId); + if (activeTab && navRef.current) { + activeTab.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + } + }, [activeId]); + + const handleClick = (id: string) => { + const el = document.getElementById(id); + if (el) { + el.scrollIntoView({ behavior: 'smooth' }); + } + }; + + return ( + + ); +} diff --git a/src/components/report/RoadmapTimeline.tsx b/src/components/report/RoadmapTimeline.tsx new file mode 100644 index 0000000..e7d243c --- /dev/null +++ b/src/components/report/RoadmapTimeline.tsx @@ -0,0 +1,81 @@ +import { motion } from 'motion/react'; +import { CheckCircle2, Circle } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import type { RoadmapMonth } from '../../types/report'; + +interface RoadmapTimelineProps { + months: RoadmapMonth[]; +} + +const monthMeta: Record = { + 1: { badge: 'from-[#6C5CE7] to-[#4F1DA1]', accent: 'border-[#6C5CE7]/20' }, + 2: { badge: 'from-[#4F1DA1] to-[#021341]', accent: 'border-[#4F1DA1]/20' }, + 3: { badge: 'from-[#021341] to-[#0A1128]', accent: 'border-[#021341]/20' }, +}; + +export default function RoadmapTimeline({ months }: RoadmapTimelineProps) { + return ( + +
+ {months.map((month, i) => { + const meta = monthMeta[month.month] || monthMeta[1]; + return ( + + {/* Header */} +
+
+ {month.month} +
+
+

+ {month.title} +

+

{month.subtitle}

+
+
+ + {/* Divider */} +
+ + {/* Task checklist */} +
    + {month.tasks.map((task, j) => ( + + {task.completed ? ( + + ) : ( + + )} + + {task.task} + + + ))} +
+ + ); + })} +
+ + ); +} diff --git a/src/components/report/TransformationProposal.tsx b/src/components/report/TransformationProposal.tsx new file mode 100644 index 0000000..128bede --- /dev/null +++ b/src/components/report/TransformationProposal.tsx @@ -0,0 +1,205 @@ +import { useState, type ComponentType } from 'react'; +import { motion } from 'motion/react'; +import { Youtube, Instagram, Facebook, Globe, Search, Star, ArrowUpRight } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { ComparisonRow } from './ui/ComparisonRow'; +import type { TransformationProposal as TransformationProposalType, PlatformStrategy } from '../../types/report'; + +interface TransformationProposalProps { + data: TransformationProposalType; +} + +const tabItems = [ + { key: 'brand', label: 'Brand Identity', labelKr: '브랜드 아이덴티티' }, + { key: 'content', label: 'Content Strategy', labelKr: '콘텐츠 전략' }, + { key: 'platform', label: 'Platform Strategies', labelKr: '플랫폼 전략' }, + { key: 'website', label: 'Website', labelKr: '웹사이트 개선' }, + { key: 'newChannel', label: 'New Channels', labelKr: '신규 채널' }, +] as const; + +type TabKey = (typeof tabItems)[number]['key']; + +const platformIconMap: Record> = { + youtube: Youtube, + instagram: Instagram, + facebook: Facebook, + website: Globe, + blog: Globe, + naver: Search, + tiktok: Star, +}; + +function PlatformStrategyCard({ strategy, index }: { key?: string | number; strategy: PlatformStrategy; index: number }) { + const Icon = platformIconMap[strategy.icon?.toLowerCase()] ?? Globe; + + return ( + +
+
+ +
+

{strategy.platform}

+
+ + {/* Current to Target */} +
+ + {strategy.currentMetric} + + + + {strategy.targetMetric} + +
+ + {/* Strategy bullets */} +
    + {strategy.strategies.map((s, i) => ( +
  • + +
    +

    {s.strategy}

    +

    {s.detail}

    +
    +
  • + ))} +
+
+ ); +} + +const priorityColor: Record = { + high: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]', + medium: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]', + low: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]', + 높음: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]', + 중간: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]', + 낮음: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]', +}; + +export default function TransformationProposal({ data }: TransformationProposalProps) { + const [activeTab, setActiveTab] = useState('brand'); + + return ( + + {/* Tabs */} +
+ {tabItems.map((tab) => ( + + ))} +
+ + {/* Brand Identity */} + {activeTab === 'brand' && ( + +

브랜드 아이덴티티

+ {data.brandIdentity.map((item, i) => ( + + ))} +
+ )} + + {/* Content Strategy */} + {activeTab === 'content' && ( + +

콘텐츠 전략

+ {data.contentStrategy.map((item, i) => ( + + ))} +
+ )} + + {/* Platform Strategies */} + {activeTab === 'platform' && ( +
+ {data.platformStrategies.map((strategy, i) => ( + + ))} +
+ )} + + {/* Website Improvements */} + {activeTab === 'website' && ( + +

웹사이트 개선

+ {data.websiteImprovements.map((item, i) => ( + + ))} +
+ )} + + {/* New Channel Proposals */} + {activeTab === 'newChannel' && ( + + + + + + + + + + + {data.newChannelProposals.map((ch, i) => ( + + + + + + ))} + +
채널우선순위근거
{ch.channel} + + {ch.priority} + + {ch.rationale}
+
+ )} +
+ ); +} diff --git a/src/components/report/YouTubeAudit.tsx b/src/components/report/YouTubeAudit.tsx new file mode 100644 index 0000000..34f90e3 --- /dev/null +++ b/src/components/report/YouTubeAudit.tsx @@ -0,0 +1,240 @@ +import { motion } from 'motion/react'; +import { Youtube, Users, Video, Eye, TrendingUp, ExternalLink } from 'lucide-react'; +import { SectionWrapper } from './ui/SectionWrapper'; +import { EmptyState } from './ui/EmptyState'; +import { MetricCard } from './ui/MetricCard'; +import { DiagnosisRow } from './ui/DiagnosisRow'; +import type { YouTubeAudit as YouTubeAuditType } from '../../types/report'; + +interface YouTubeAuditProps { + data: YouTubeAuditType; +} + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +export default function YouTubeAudit({ data }: YouTubeAuditProps) { + const hasData = data.subscribers > 0 || data.totalVideos > 0 || data.topVideos.length > 0 || data.diagnosis.length > 0; + + return ( + + {!hasData && ( + + )} + + {hasData && <> + {/* Metrics row */} +
+ } + subtext={data.subscriberRank} + /> + } + /> + } + /> + } + subtext={`${data.weeklyViewGrowth.percentage > 0 ? '+' : ''}${data.weeklyViewGrowth.percentage}%`} + trend={data.weeklyViewGrowth.percentage > 0 ? 'up' : data.weeklyViewGrowth.percentage < 0 ? 'down' : 'neutral'} + /> +
+ + {/* Channel info card */} + +
+
+ +
+
+

{data.channelName}

+ {data.handle ? ( + + {data.handle} + + + ) : ( +

{data.handle}

+ )} +
+
+

{data.channelDescription}

+
+ 개설일: {data.channelCreatedDate} + 평균 영상 길이: {data.avgVideoLength} + 업로드 빈도: {data.uploadFrequency} +
+ + {/* Linked URLs */} + {data.linkedUrls.length > 0 && ( +
+ {data.linkedUrls.map((link) => ( + + + {link.label} + + ))} +
+ )} +
+ + {/* Playlists */} + {data.playlists.length > 0 && ( + +

재생목록

+
+ {data.playlists.map((pl) => ( + + {pl} + + ))} +
+
+ )} + + {/* Top Videos */} + {data.topVideos.length > 0 && ( + +

인기 영상 TOP {data.topVideos.length}

+
+ {data.topVideos.map((video, i) => ( + +
+ + {video.type} + + {video.uploadedAgo} +
+

+ {video.title} +

+
+ + {formatNumber(video.views)} + views +
+
+ ))} +
+
+ )} + + {/* Diagnosis — metric-based findings */} + {(data.diagnosis.length > 0 || data.subscribers > 0) && ( + +
+

진단 결과

+
+ + {/* Quick metric diagnosis rows */} + {data.subscribers > 0 && data.totalViews > 0 && ( +
+ 구독자 대비 조회수 비율 + + 영상당 평균 ~{formatNumber(data.totalVideos > 0 ? Math.round(data.totalViews / data.totalVideos) : 0)}회 ({data.totalVideos > 0 && data.subscribers > 0 ? `${Math.round((data.totalViews / data.totalVideos / data.subscribers) * 100)}%` : '-'} 구독자 대비 {data.subscribers > 100000 ? '5' : '9'}% 달성) + +
+ )} + + {data.topVideos.length > 0 && ( +
+ 최근 롱폼 조회수 + + 대부분 1,000~4,000회 수준 + +
+ )} + + {data.topVideos.filter(v => v.type === 'Short').length === 0 && data.totalVideos > 0 && ( +
+ Shorts 조회수 + 최근 업로드 500~1000회 (유기적 도달 금지) +
+ )} + +
+ 업로드 빈도 + + {data.uploadFrequency || '주 1회'} — 알고리즘 노출 기준 최소 주 3회 이상 필요 + +
+ + {/* Detailed diagnosis items */} + {data.diagnosis.length > 0 && ( +
+ {data.diagnosis.map((item, i) => ( + + ))} +
+ )} +
+ )} + } +
+ ); +} diff --git a/src/components/report/ui/ComparisonRow.tsx b/src/components/report/ui/ComparisonRow.tsx new file mode 100644 index 0000000..de82594 --- /dev/null +++ b/src/components/report/ui/ComparisonRow.tsx @@ -0,0 +1,20 @@ +interface ComparisonRowProps { + key?: string | number; + area: string; + asIs: string; + toBe: string; +} + +export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) { + return ( +
+ {area} +
+ {asIs} +
+
+ {toBe} +
+
+ ); +} diff --git a/src/components/report/ui/DiagnosisRow.tsx b/src/components/report/ui/DiagnosisRow.tsx new file mode 100644 index 0000000..05c7fcc --- /dev/null +++ b/src/components/report/ui/DiagnosisRow.tsx @@ -0,0 +1,28 @@ +import type { Severity } from '../../../types/report'; +import { SeverityBadge } from './SeverityBadge'; +import { EvidenceGallery } from './EvidenceGallery'; + +interface DiagnosisRowProps { + key?: string | number; + category: string; + detail: string; + severity: Severity; + evidenceIds?: string[]; +} + +export function DiagnosisRow({ category, detail, severity, evidenceIds }: DiagnosisRowProps) { + return ( +
+
+ + {category} + +

{detail}

+
+ +
+
+ +
+ ); +} diff --git a/src/components/report/ui/EmptyState.tsx b/src/components/report/ui/EmptyState.tsx new file mode 100644 index 0000000..561eb44 --- /dev/null +++ b/src/components/report/ui/EmptyState.tsx @@ -0,0 +1,109 @@ +import { motion } from 'motion/react'; +import { Search, AlertCircle, Info, RefreshCw } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +type EmptyStatus = 'loading' | 'error' | 'not_found' | 'timeout'; + +interface EmptyStateProps { + message?: string; + subtext?: string; + status?: EmptyStatus; + onRetry?: () => void; + /** Auto-timeout: switch to 'timeout' status after N seconds (default: 60) */ + autoTimeoutSec?: number; +} + +const STATUS_CONFIG: Record = { + loading: { + icon: Search, + iconColor: 'text-slate-400', + bgColor: 'bg-slate-100', + defaultMessage: '데이터 수집 중', + defaultSubtext: '채널 데이터 보강이 완료되면 자동으로 업데이트됩니다.', + }, + error: { + icon: AlertCircle, + iconColor: 'text-red-400', + bgColor: 'bg-red-50', + defaultMessage: '데이터 수집 실패', + defaultSubtext: '일시적인 오류가 발생했습니다. 다시 시도해 주세요.', + }, + not_found: { + icon: Info, + iconColor: 'text-blue-400', + bgColor: 'bg-blue-50', + defaultMessage: '채널을 찾을 수 없음', + defaultSubtext: '이 채널은 발견되지 않았거나 데이터가 없습니다.', + }, + timeout: { + icon: AlertCircle, + iconColor: 'text-amber-400', + bgColor: 'bg-amber-50', + defaultMessage: '응답 시간 초과', + defaultSubtext: '데이터 수집에 시간이 오래 걸리고 있습니다.', + }, +}; + +/** + * Shown inside report sections when data is not yet available + * (e.g., before enrichment completes, when a channel is not found, or on error). + */ +export function EmptyState({ + message, + subtext, + status = 'loading', + onRetry, + autoTimeoutSec = 60, +}: EmptyStateProps) { + const [currentStatus, setCurrentStatus] = useState(status); + + // Auto-timeout: switch from 'loading' to 'timeout' after N seconds + useEffect(() => { + if (status !== 'loading') return; + const timer = setTimeout(() => setCurrentStatus('timeout'), autoTimeoutSec * 1000); + return () => clearTimeout(timer); + }, [status, autoTimeoutSec]); + + // Sync external status changes + useEffect(() => { + setCurrentStatus(status); + }, [status]); + + const config = STATUS_CONFIG[currentStatus]; + const Icon = config.icon; + + return ( + +
+ {currentStatus === 'loading' ? ( +
+ ) : ( + + )} +
+

{message || config.defaultMessage}

+

{subtext || config.defaultSubtext}

+ + {onRetry && (currentStatus === 'error' || currentStatus === 'timeout') && ( + + )} + + ); +} diff --git a/src/components/report/ui/EvidenceGallery.tsx b/src/components/report/ui/EvidenceGallery.tsx new file mode 100644 index 0000000..d260bb6 --- /dev/null +++ b/src/components/report/ui/EvidenceGallery.tsx @@ -0,0 +1,34 @@ +import { useScreenshots } from '../../../contexts/ScreenshotContext'; +import { EvidenceScreenshot } from './EvidenceScreenshot'; + +interface EvidenceGalleryProps { + evidenceIds?: string[]; + compact?: boolean; +} + +export function EvidenceGallery({ evidenceIds, compact }: EvidenceGalleryProps) { + const { getByIds } = useScreenshots(); + + if (!evidenceIds?.length) return null; + + const items = getByIds(evidenceIds); + if (!items.length) return null; + + if (items.length === 1) { + return ( +
+ +
+ ); + } + + return ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} diff --git a/src/components/report/ui/EvidenceLightbox.tsx b/src/components/report/ui/EvidenceLightbox.tsx new file mode 100644 index 0000000..5e56592 --- /dev/null +++ b/src/components/report/ui/EvidenceLightbox.tsx @@ -0,0 +1,105 @@ +import { useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import type { ScreenshotEvidence } from '../../../types/report'; + +interface EvidenceLightboxProps { + evidence: ScreenshotEvidence | null; + onClose: () => void; +} + +export function EvidenceLightbox({ evidence, onClose }: EvidenceLightboxProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }, + [onClose], + ); + + useEffect(() => { + if (evidence) { + document.addEventListener('keydown', handleKeyDown); + document.body.style.overflow = 'hidden'; + } + return () => { + document.removeEventListener('keydown', handleKeyDown); + document.body.style.overflow = ''; + }; + }, [evidence, handleKeyDown]); + + return ( + + {evidence && ( + + {/* Backdrop */} +
+ + {/* Content */} + + {/* Close button */} + + + {/* Image container with annotations */} +
+ {evidence.caption} + + {/* Annotation overlays */} + {evidence.annotations?.map((ann, i) => ( +
+ {ann.label && ( + + {ann.label} + + )} +
+ ))} +
+ + {/* Caption */} +
+

{evidence.caption}

+ {evidence.sourceUrl && ( +

{evidence.sourceUrl}

+ )} +
+
+ + )} + + ); +} diff --git a/src/components/report/ui/EvidenceScreenshot.tsx b/src/components/report/ui/EvidenceScreenshot.tsx new file mode 100644 index 0000000..ad6f3c2 --- /dev/null +++ b/src/components/report/ui/EvidenceScreenshot.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import type { ScreenshotEvidence } from '../../../types/report'; +import { EvidenceLightbox } from './EvidenceLightbox'; + +interface EvidenceScreenshotProps { + evidence: ScreenshotEvidence; + compact?: boolean; +} + +export function EvidenceScreenshot({ evidence, compact }: EvidenceScreenshotProps) { + const [loaded, setLoaded] = useState(true); + const [lightboxOpen, setLightboxOpen] = useState(false); + + if (!loaded) return null; + + return ( + <> + + + setLightboxOpen(false)} + /> + + ); +} diff --git a/src/components/report/ui/MetricCard.tsx b/src/components/report/ui/MetricCard.tsx new file mode 100644 index 0000000..ad2f36c --- /dev/null +++ b/src/components/report/ui/MetricCard.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from 'react'; +import { ArrowUp, ArrowDown, Minus } from 'lucide-react'; + +interface MetricCardProps { + key?: string | number; + label: string; + value: string | number; + subtext?: string; + icon?: ReactNode; + trend?: 'up' | 'down' | 'neutral'; +} + +const trendConfig = { + up: { icon: ArrowUp, color: 'text-emerald-500' }, + down: { icon: ArrowDown, color: 'text-red-500' }, + neutral: { icon: Minus, color: 'text-slate-400' }, +}; + +export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +

{label}

+
+ {value} + {trend && ( + + {(() => { + const TrendIcon = trendConfig[trend].icon; + return ; + })()} + + )} +
+ {subtext && ( +

{subtext}

+ )} +
+ ); +} diff --git a/src/components/report/ui/ScoreRing.tsx b/src/components/report/ui/ScoreRing.tsx new file mode 100644 index 0000000..bc392e9 --- /dev/null +++ b/src/components/report/ui/ScoreRing.tsx @@ -0,0 +1,73 @@ +import { motion } from 'motion/react'; + +interface ScoreRingProps { + score: number; + maxScore?: number; + size?: number; + label?: string; + color?: string; +} + +function getScoreColor(score: number, maxScore: number): string { + const pct = (score / maxScore) * 100; + if (pct <= 40) return '#C084CF'; // soft violet — critical + if (pct <= 60) return '#8B9CF7'; // periwinkle blue — caution + if (pct <= 80) return '#7C6DD8'; // medium purple — good + return '#6C5CE7'; // Infinith primary purple — excellent +} + +export function ScoreRing({ + score, + maxScore = 100, + size = 120, + label, + color, +}: ScoreRingProps) { + const strokeWidth = 8; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const progress = Math.min(score / maxScore, 1); + const strokeDashoffset = circumference * (1 - progress); + const resolvedColor = color ?? getScoreColor(score, maxScore); + + return ( +
+
+ + + + +
+ {score} +
+
+ {label && ( + {label} + )} +
+ ); +} diff --git a/src/components/report/ui/SectionErrorBoundary.tsx b/src/components/report/ui/SectionErrorBoundary.tsx new file mode 100644 index 0000000..bce8f44 --- /dev/null +++ b/src/components/report/ui/SectionErrorBoundary.tsx @@ -0,0 +1,34 @@ +import { Component, type ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; +} + +export class SectionErrorBoundary extends Component { + declare props: Props; + state: State = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error: Error) { + console.error('[SectionErrorBoundary]', error.message, error.stack); + } + + render() { + if (this.state.hasError) { + return this.props.fallback ?? ( +
+

섹션 렌더링 오류 (콘솔 확인)

+
+ ); + } + return this.props.children; + } +} diff --git a/src/components/report/ui/SectionWrapper.tsx b/src/components/report/ui/SectionWrapper.tsx new file mode 100644 index 0000000..d124c9d --- /dev/null +++ b/src/components/report/ui/SectionWrapper.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; + +interface SectionWrapperProps { + id: string; + title: string; + subtitle?: string; + children: ReactNode; + dark?: boolean; + className?: string; +} + +export function SectionWrapper({ + id, + title, + subtitle, + children, + dark = false, + className = '', +}: SectionWrapperProps) { + return ( +
+ {dark && ( +
+ )} +
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ {children} +
+
+ ); +} diff --git a/src/components/report/ui/SeverityBadge.tsx b/src/components/report/ui/SeverityBadge.tsx new file mode 100644 index 0000000..bdf4e43 --- /dev/null +++ b/src/components/report/ui/SeverityBadge.tsx @@ -0,0 +1,41 @@ +type SeverityLevel = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown'; + +interface SeverityBadgeProps { + severity: SeverityLevel; + label?: string; +} + +const config: Record = { + critical: { + className: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]', + defaultLabel: '심각', + }, + warning: { + className: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]', + defaultLabel: '주의', + }, + good: { + className: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]', + defaultLabel: '양호', + }, + excellent: { + className: 'bg-[#EFF0FF] text-[#3A3F7C] border-[#C5CBF5]', + defaultLabel: '우수', + }, + unknown: { + className: 'bg-slate-50 text-slate-700 border-slate-200', + defaultLabel: '미확인', + }, +}; + +export function SeverityBadge({ severity, label }: SeverityBadgeProps) { + const { className, defaultLabel } = config[severity]; + + return ( + + {label ?? defaultLabel} + + ); +} diff --git a/src/hooks/useAnalysis.ts b/src/hooks/useAnalysis.ts new file mode 100644 index 0000000..b1dbb42 --- /dev/null +++ b/src/hooks/useAnalysis.ts @@ -0,0 +1,76 @@ +/** + * useAnalysis — start an analysis + poll status until terminal. + * + * Contract lives in infinith-api/docs/API_CONTRACT.md §2-3. + * Replace `any` with generated types from `src/api-types/schema.d.ts` + * once backend `openapi.json` is stable. + */ +import { useEffect, useRef, useState } from 'react' +import { apiClient } from '@/lib/apiClient' + +type Status = 'pending' | 'discovering' | 'collecting' | 'generating' | 'complete' | 'partial' | 'error' + +interface StatusResponse { + analysis_run_id: string + status: Status + progress: number + current_step: string + channel_errors: Record + completed_at: string | null +} + +interface ChannelHandles { + youtube?: string + instagram?: string[] + facebook?: string + naver_blog?: string + gangnam_unni?: string +} + +const TERMINAL: Status[] = ['complete', 'partial', 'error'] + +export function useAnalysis() { + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const pollerRef = useRef(null) + + useEffect( + () => () => { + if (pollerRef.current) window.clearInterval(pollerRef.current) + }, + [], + ) + + async function start(input: { clinic_id?: string; url?: string; channels: ChannelHandles }) { + try { + const { data } = await apiClient.post('/api/analyses', input) + pollStatus(data.analysis_run_id) + return data.analysis_run_id as string + } catch (err) { + setError(err as Error) + throw err + } + } + + function pollStatus(runId: string) { + if (pollerRef.current) window.clearInterval(pollerRef.current) + pollerRef.current = window.setInterval(async () => { + try { + const { data } = await apiClient.get(`/api/analyses/${runId}/status`) + setStatus(data) + if (TERMINAL.includes(data.status) && pollerRef.current) { + window.clearInterval(pollerRef.current) + pollerRef.current = null + } + } catch (err) { + setError(err as Error) + if (pollerRef.current) { + window.clearInterval(pollerRef.current) + pollerRef.current = null + } + } + }, 2000) + } + + return { start, pollStatus, status, error } +} diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts new file mode 100644 index 0000000..be0b7bc --- /dev/null +++ b/src/hooks/useReport.ts @@ -0,0 +1,50 @@ +/** + * useReport — fetch a completed report from the API. + * + * Prototype version had DEMO_REPORTS hardcoded mapping — REMOVED. + * This MVP hook is API-only; no fallback mocks. + * + * TODO (D5): after transformReport.ts is ported, pipe raw → transformed + * so pages continue to receive the shape they expect. + */ +import { useEffect, useState } from 'react' +import { apiClient } from '@/lib/apiClient' +import type { MarketingReport } from '@/types/report' + +interface UseReportResult { + data: MarketingReport | null + isLoading: boolean + error: Error | null +} + +export function useReport(runId: string | undefined): UseReportResult { + const [data, setData] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!runId) return + let cancelled = false + + ;(async () => { + try { + setIsLoading(true) + const { data: raw } = await apiClient.get(`/api/reports/${runId}`) + if (!cancelled) { + // TODO: run through transformReport(raw) once util is ported + setData(raw as MarketingReport) + } + } catch (err) { + if (!cancelled) setError(err as Error) + } finally { + if (!cancelled) setIsLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [runId]) + + return { data, isLoading, error } +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..a6abbe0 --- /dev/null +++ b/src/index.css @@ -0,0 +1,32 @@ +@import 'tailwindcss'; + +@theme { + --font-sans: 'Pretendard Variable', 'Pretendard', ui-sans-serif, system-ui, + -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-serif: 'Playfair Display', Georgia, serif; + + /* Brand palette — ported from prototype */ + --color-primary-900: #0a1128; + --color-primary-800: #121b3a; + --color-primary-700: #1d2a52; + --color-accent: #6c5ce7; + --color-accent-dark: #584ac7; + + /* Status colors */ + --color-status-critical: #ef4444; + --color-status-warning: #f59e0b; + --color-status-good: #10b981; + --color-status-info: #3b82f6; +} + +@layer base { + body { + @apply bg-primary-900 text-white font-sans antialiased; + } +} + +@layer components { + .glass-card { + @apply rounded-2xl border border-white/10 bg-white/5 backdrop-blur-md; + } +} diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts new file mode 100644 index 0000000..7444a9c --- /dev/null +++ b/src/lib/apiClient.ts @@ -0,0 +1,36 @@ +/** + * Centralized axios instance — adds X-API-Key header and base URL. + * + * `VITE_API_BASE_URL` is read from `.env.development` / `.env.production`. + * In dev, Vite proxy also forwards `/api/*` → `http://localhost:8000` so + * relative paths work without CORS. + */ +import axios from 'axios' + +export const apiClient = axios.create({ + baseURL: import.meta.env['VITE_API_BASE_URL'] ?? '', + headers: { + 'Content-Type': 'application/json', + }, +}) + +apiClient.interceptors.request.use((config) => { + const apiKey = import.meta.env['VITE_API_KEY'] + if (apiKey) { + config.headers['X-API-Key'] = apiKey + } + return config +}) + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + // Surface FastAPI's structured 422/4xx detail to callers + if (error.response?.data?.detail) { + error.message = Array.isArray(error.response.data.detail) + ? error.response.data.detail.map((d: { msg: string }) => d.msg).join(', ') + : error.response.data.detail + } + return Promise.reject(error) + }, +) diff --git a/src/lib/transformPlan.ts b/src/lib/transformPlan.ts new file mode 100644 index 0000000..af7ceec --- /dev/null +++ b/src/lib/transformPlan.ts @@ -0,0 +1,417 @@ +import type { MarketingPlan, ChannelStrategyCard, ContentPillar, AssetCard, YouTubeRepurposeItem } from '../types/plan'; +import type { EnrichmentData } from './transformReport'; +import { generateContentPlan } from './contentDirector'; + +/** + * Raw report data from Supabase marketing_reports table. + * The `report` JSONB contains AI-generated analysis. + */ +interface RawReportRow { + id: string; + url: string; + clinic_name: string; + report: Record; + scrape_data?: Record; + analysis_data?: Record; + created_at: string; +} + +const CHANNEL_NAME_MAP: Record = { + naverBlog: '네이버 블로그', + naverPlace: '네이버 플레이스', + gangnamUnni: '강남언니', + instagram: 'Instagram', + youtube: 'YouTube', + facebook: 'Facebook', + website: '웹사이트', + tiktok: 'TikTok', +}; + +const CHANNEL_ICON_MAP: Record = { + naverBlog: 'blog', + instagram: 'instagram', + youtube: 'youtube', + facebook: 'facebook', + naverPlace: 'map', + gangnamUnni: 'star', + website: 'globe', + tiktok: 'video', +}; + +// Channel-specific tone matrix (Audit C3) +const CHANNEL_TONE_MAP: Record = { + youtube: '교육적 · 권위 (Long) / 캐주얼 · 후킹 (Shorts)', + instagram: '트렌디 · 공감 (Reel) / 감성적 · 프리미엄 (Feed)', + naverBlog: '정보성 · SEO 최적화', + gangnamUnni: '전문 · 응대 · 신뢰', + facebook: '타겟팅 · CTA 중심', + tiktok: '밈 · 교육 · MZ세대', + naverPlace: '정보성 · 지역 SEO', + website: '브랜드 · 프리미엄', +}; + +function buildChannelStrategies( + channelAnalysis: Record> | undefined, + recommendations: Record[] | undefined, +): ChannelStrategyCard[] { + if (!channelAnalysis) return []; + + return Object.entries(channelAnalysis).map(([key, ch], i) => { + const score = (ch.score as number) ?? 0; + const relatedRecs = (recommendations || []) + .filter(r => { + const cat = ((r.category as string) || '').toLowerCase(); + return cat.includes(key.toLowerCase()) || cat.includes(CHANNEL_NAME_MAP[key]?.toLowerCase() || ''); + }) + .map(r => (r.title as string) || (r.description as string) || ''); + + return { + channelId: key, + channelName: CHANNEL_NAME_MAP[key] || key, + icon: CHANNEL_ICON_MAP[key] || 'globe', + currentStatus: `점수: ${score}/100 (${(ch.status as string) || 'unknown'})`, + targetGoal: (ch.recommendation as string) || '', + contentTypes: relatedRecs.length > 0 ? relatedRecs : ['콘텐츠 전략 수립 필요'], + postingFrequency: score >= 80 ? '주 3-5회' : score >= 60 ? '주 2-3회' : '주 1-2회 (시작)', + tone: CHANNEL_TONE_MAP[key] || '전문적 · 친근한', + formatGuidelines: [], + priority: (score < 50 ? 'P0' : score < 70 ? 'P1' : 'P2') as 'P0' | 'P1' | 'P2', + }; + }); +} + +function buildContentPillars( + recommendations: Record[] | undefined, + services: string[] | undefined, +): ContentPillar[] { + const PILLAR_COLORS = ['#6C5CE7', '#E17055', '#00B894', '#FDCB6E', '#0984E3']; + const pillars: ContentPillar[] = [ + { + title: '전문성 · 신뢰', + description: '의료진 소개, 수술 과정, 인증/자격, 학회 발표, 해외 연수 콘텐츠로 신뢰 구축', + relatedUSP: '전문 의료진', + exampleTopics: services?.slice(0, 3).map(s => `${s} 시술 과정 소개`) || ['시술 과정 소개'], + color: PILLAR_COLORS[0], + }, + { + title: '비포 · 애프터', + description: '실제 환자 사례, 수술 전후 비교, 3D 시뮬레이션, 시간별 변화 기록으로 결과 시각화', + relatedUSP: '검증된 결과', + exampleTopics: services?.slice(0, 3).map(s => `${s} 비포/애프터`) || ['비포/애프터 사례'], + color: PILLAR_COLORS[1], + }, + { + title: '환자 후기 · 리뷰', + description: '실제 환자 인터뷰, 후기 콘텐츠, 강남언니 리뷰 관리로 사회적 증거 확보', + relatedUSP: '환자 만족도', + exampleTopics: ['환자 인터뷰 영상', '리뷰 하이라이트', '회복 일기'], + color: PILLAR_COLORS[2], + }, + { + title: '트렌드 · 교육', + description: '시술 트렌드, Q&A, 의학 정보, 비용 가이드로 잠재 고객 유입', + relatedUSP: '최신 트렌드', + exampleTopics: ['자주 묻는 질문 Q&A', '시술별 비용 가이드', '최신 성형 트렌드'], + color: PILLAR_COLORS[3], + }, + { + title: '안전 · 케어', + description: '수술 후 관리, 24시간 모니터링, 전담 간호사, 리커버리 프로그램으로 프리미엄 케어 차별화', + relatedUSP: '프리미엄 안전 관리', + exampleTopics: ['수술 후 48시간 집중 케어', '전담 간호사 1:1 관리', '응급 대응 프로토콜'], + color: PILLAR_COLORS[4], + }, + ]; + + return pillars; +} + +/** + * Build calendar is now delegated to the Content Director engine, + * which generates a rich 4-week editorial plan based on channels, pillars, + * services, and existing assets. + */ +function buildCalendar( + channels: ChannelStrategyCard[], + pillars: ContentPillar[], + services: string[], + enrichment: EnrichmentData | undefined, + clinicName: string, + report?: Record, +) { + // Extract YouTube top videos for repurposing suggestions + const youtubeVideos = enrichment?.youtube?.videos?.map(v => ({ + title: v.title || '', + views: v.views || 0, + type: ((v.duration && parseInt(v.duration) < 60) ? 'Short' : 'Long') as 'Short' | 'Long', + })); + + // Extract keywords from report for topic generation (Audit C5) + const reportKeywords = report?.keywords as { primary?: { keyword: string; monthlySearches?: number }[] } | undefined; + const keywords = reportKeywords?.primary; + + // Extract 강남언니 data for strategy-driven topics (Audit C1) + const guData = enrichment?.gangnamUnni as { rating?: number; totalReviews?: number; doctors?: { name: string }[] } | undefined; + const gangnamUnniData = guData ? { + rating: guData.rating, + reviews: guData.totalReviews, + doctors: guData.doctors?.length, + } : undefined; + + return generateContentPlan({ + channels, + pillars, + services, + youtubeVideos, + clinicName, + keywords, + gangnamUnniData, + }); +} + +function buildAssets(enrichment: EnrichmentData | undefined): { assets: AssetCard[]; youtubeRepurpose: YouTubeRepurposeItem[] } { + if (!enrichment) return { assets: [], youtubeRepurpose: [] }; + + const assets: AssetCard[] = []; + let assetIdx = 0; + + // YouTube videos → video assets + if (enrichment.youtube?.videos) { + for (const v of enrichment.youtube.videos.slice(0, 5)) { + assets.push({ + id: `yt-${assetIdx++}`, + source: 'youtube', + sourceLabel: 'YouTube', + type: 'video', + title: v.title || '영상', + description: `조회수 ${(v.views || 0).toLocaleString()} · 좋아요 ${(v.likes || 0).toLocaleString()}`, + repurposingSuggestions: ['Shorts 추출', '블로그 스크립트', '카드뉴스'], + status: 'collected', + }); + } + } + + // Instagram posts → photo assets + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + for (const ig of igAccounts) { + if (ig.latestPosts) { + for (const p of (ig.latestPosts as { type?: string; likes?: number; caption?: string }[]).slice(0, 3)) { + assets.push({ + id: `ig-${assetIdx++}`, + source: 'social', + sourceLabel: `Instagram @${ig.username || ''}`, + type: 'photo', + title: (p.caption || '').slice(0, 60) || 'Instagram 게시물', + description: `좋아요 ${(p.likes || 0).toLocaleString()}`, + repurposingSuggestions: ['피드 리포스트', '스토리 하이라이트'], + status: 'collected', + }); + } + } + } + + // Naver blog posts → text assets + if (enrichment.naverBlog?.posts) { + for (const post of enrichment.naverBlog.posts.slice(0, 3)) { + assets.push({ + id: `nb-${assetIdx++}`, + source: 'blog', + sourceLabel: '네이버 블로그', + type: 'text', + title: post.title || '블로그 포스트', + description: post.description || '', + repurposingSuggestions: ['SNS 카드뉴스', '영상 스크립트'], + status: 'collected', + }); + } + } + + // YouTube repurpose items + const youtubeRepurpose: YouTubeRepurposeItem[] = (enrichment.youtube?.videos || []) + .slice(0, 5) + .map(v => ({ + title: v.title || '', + views: v.views || 0, + type: ((v.duration && parseInt(v.duration) < 60) ? 'Short' : 'Long') as 'Short' | 'Long', + repurposeAs: ['Shorts 3개 추출', '블로그 포스트 변환', '카드뉴스 4장', '카카오톡 CTA'], + })); + + return { assets, youtubeRepurpose }; +} + +function buildChannelBrandingFromEnrichment(enrichment: EnrichmentData | undefined) { + if (!enrichment) return []; + + const rules: { channel: string; icon: string; profilePhoto: string; bannerSpec: string; bioTemplate: string; currentStatus: 'correct' | 'incorrect' | 'missing' }[] = []; + + if (enrichment.youtube) { + rules.push({ + channel: 'YouTube', + icon: 'youtube', + profilePhoto: enrichment.youtube.thumbnailUrl || '', + bannerSpec: '2560×1440px 배너', + bioTemplate: enrichment.youtube.description?.slice(0, 200) || '', + currentStatus: enrichment.youtube.description ? 'correct' : 'missing', + }); + } + + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + for (const ig of igAccounts) { + rules.push({ + channel: `Instagram @${ig.username || ''}`, + icon: 'instagram', + profilePhoto: '', + bannerSpec: 'N/A (하이라이트 커버)', + bioTemplate: ig.bio || '', + currentStatus: ig.bio ? 'correct' : 'missing', + }); + } + + if (enrichment.facebook) { + rules.push({ + channel: 'Facebook', + icon: 'facebook', + profilePhoto: enrichment.facebook.profilePictureUrl || '', + bannerSpec: '820×312px 커버 사진', + bioTemplate: enrichment.facebook.intro || '', + currentStatus: enrichment.facebook.intro ? 'correct' : 'missing', + }); + } + + return rules; +} + +function buildBrandInconsistencies(enrichment: EnrichmentData | undefined, clinicName: string): { field: string; values: { channel: string; value: string; isCorrect: boolean }[]; impact: string; recommendation: string }[] { + if (!enrichment) return []; + const items: { field: string; values: { channel: string; value: string; isCorrect: boolean }[]; impact: string; recommendation: string }[] = []; + + // Collect names across channels + const names: { channel: string; value: string }[] = []; + if (clinicName) names.push({ channel: '웹사이트', value: clinicName }); + if (enrichment.youtube?.channelName) names.push({ channel: 'YouTube', value: enrichment.youtube.channelName }); + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + for (const ig of igAccounts) { + if (ig.username) names.push({ channel: `Instagram @${ig.username}`, value: ig.username }); + } + if (enrichment.facebook?.pageName) names.push({ channel: 'Facebook', value: enrichment.facebook.pageName }); + if (enrichment.gangnamUnni?.name) names.push({ channel: '강남언니', value: enrichment.gangnamUnni.name }); + + if (names.length >= 2) { + const websiteName = names[0].value; + items.push({ + field: '병원명', + values: names.map(n => ({ channel: n.channel, value: n.value, isCorrect: n.value.toLowerCase().includes(websiteName.toLowerCase().slice(0, 4)) })), + impact: '채널마다 다른 이름은 브랜드 인지도를 분산시킵니다', + recommendation: '모든 채널에서 동일한 공식 병원명을 사용하세요', + }); + } + + // Collect phone numbers + const phones: { channel: string; value: string }[] = []; + if (enrichment.googleMaps?.phone) phones.push({ channel: 'Google Maps', value: enrichment.googleMaps.phone as string }); + if (enrichment.naverPlace?.telephone) phones.push({ channel: '네이버 플레이스', value: enrichment.naverPlace.telephone }); + if (enrichment.facebook?.phone) phones.push({ channel: 'Facebook', value: enrichment.facebook.phone as string }); + if (phones.length >= 2) { + const ref = phones[0].value.replace(/[^0-9]/g, ''); + items.push({ + field: '연락처', + values: phones.map(p => ({ channel: p.channel, value: p.value, isCorrect: p.value.replace(/[^0-9]/g, '') === ref })), + impact: '다른 전화번호는 고객 혼란을 유발합니다', + recommendation: '모든 플랫폼에 동일한 대표 전화번호를 등록하세요', + }); + } + + return items; +} + +function buildBrandColors(branding: Record | undefined): { name: string; hex: string; usage: string }[] { + if (!branding) return []; + const colors: { name: string; hex: string; usage: string }[] = []; + if (branding.primaryColor) colors.push({ name: 'Primary', hex: branding.primaryColor as string, usage: '메인 브랜드 색상' }); + if (branding.accentColor) colors.push({ name: 'Accent', hex: branding.accentColor as string, usage: '강조 색상' }); + if (branding.backgroundColor) colors.push({ name: 'Background', hex: branding.backgroundColor as string, usage: '배경 색상' }); + if (branding.textColor) colors.push({ name: 'Text', hex: branding.textColor as string, usage: '본문 텍스트' }); + return colors; +} + +function buildBrandFonts(branding: Record | undefined): { family: string; weight: string; usage: string; sampleText: string }[] { + if (!branding) return []; + const fonts: { family: string; weight: string; usage: string; sampleText: string }[] = []; + if (branding.headingFont) fonts.push({ family: branding.headingFont as string, weight: 'Bold', usage: '제목/헤딩', sampleText: '안전이 예술이 되는 곳' }); + if (branding.bodyFont) fonts.push({ family: branding.bodyFont as string, weight: 'Regular', usage: '본문 텍스트', sampleText: '프리미엄 의료 서비스를 경험하세요' }); + return fonts; +} + +/** + * Transform a raw Supabase report row into a MarketingPlan. + * Uses report data (channel analysis, recommendations, services) + * to dynamically generate plan content. + */ +export function transformReportToPlan(row: RawReportRow): MarketingPlan { + const report = row.report; + const clinicInfo = report.clinicInfo as Record | undefined; + const channelAnalysis = report.channelAnalysis as Record> | undefined; + const recommendations = report.recommendations as Record[] | undefined; + const services = (clinicInfo?.services as string[]) || []; + const enrichment = report.channelEnrichment as EnrichmentData | undefined; + const scrapeData = row.scrape_data as Record | undefined; + const branding = scrapeData?.branding as Record | undefined; + + const channelStrategies = buildChannelStrategies(channelAnalysis, recommendations); + const pillars = buildContentPillars(recommendations, services); + const clinicName = (clinicInfo?.name as string) || row.clinic_name || ''; + const calendar = buildCalendar(channelStrategies, pillars, services, enrichment, clinicName, report); + + return { + id: row.id, + reportId: row.id, + clinicName: (clinicInfo?.name as string) || row.clinic_name || '', + clinicNameEn: (clinicInfo?.nameEn as string) || '', + createdAt: row.created_at, + targetUrl: row.url, + + brandGuide: { + colors: buildBrandColors(branding), + fonts: buildBrandFonts(branding), + logoRules: [], + toneOfVoice: { + personality: ['전문적', '친근한', '신뢰할 수 있는'], + communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달', + doExamples: ['정확한 의학 용어 사용', '환자 성공 사례 공유', '전문의 인사이트 제공'], + dontExamples: ['과장된 효과 주장', '비교 광고', '의학적 보장 표현'], + }, + channelBranding: buildChannelBrandingFromEnrichment(enrichment), + brandInconsistencies: buildBrandInconsistencies(enrichment, (clinicInfo?.name as string) || row.clinic_name || ''), + }, + + channelStrategies, + + contentStrategy: { + pillars, + typeMatrix: [ + { format: '숏폼 영상 (60초)', channels: ['YouTube Shorts', 'Instagram Reels', 'TikTok'], frequency: '주 3-5회', purpose: '신규 유입 + 인지도' }, + { format: '롱폼 영상 (5-15분)', channels: ['YouTube'], frequency: '주 1회', purpose: '전문성 + 검색 SEO' }, + { format: '블로그 포스트', channels: ['네이버 블로그'], frequency: '주 2-3회', purpose: 'SEO + 상세 정보' }, + { format: '피드 이미지', channels: ['Instagram', 'Facebook'], frequency: '주 3회', purpose: '브랜드 인지도' }, + ], + workflow: [ + { step: 1, name: '기획', description: '콘텐츠 주제 선정 + 키워드 리서치', owner: 'AI + 마케터', duration: '30분' }, + { step: 2, name: '제작', description: 'AI 초안 생성 + 의료진 감수', owner: 'INFINITH AI', duration: '1시간' }, + { step: 3, name: '편집', description: '영상/이미지 편집 + 자막 추가', owner: 'INFINITH Studio', duration: '30분' }, + { step: 4, name: '배포', description: '채널별 최적화 + 스케줄 배포', owner: 'INFINITH Distribution', duration: '자동' }, + { step: 5, name: '분석', description: '성과 데이터 수집 + 최적화 제안', owner: 'INFINITH Analytics', duration: '자동' }, + ], + repurposingSource: '1개 롱폼 영상', + repurposingOutputs: [ + { format: '숏폼 영상 3개', channel: 'YouTube Shorts / Instagram Reels', description: '핵심 장면 추출' }, + { format: '블로그 포스트', channel: '네이버 블로그', description: '영상 스크립트 기반 SEO 글' }, + { format: '피드 이미지 4장', channel: 'Instagram / Facebook', description: '핵심 정보 카드뉴스' }, + { format: '카카오톡 메시지', channel: 'KakaoTalk', description: '환자 타겟 CTA 메시지' }, + ], + }, + + calendar, + + assetCollection: buildAssets(enrichment), + }; +} diff --git a/src/lib/transformReport.ts b/src/lib/transformReport.ts new file mode 100644 index 0000000..2664445 --- /dev/null +++ b/src/lib/transformReport.ts @@ -0,0 +1,1268 @@ +import type { MarketingReport, Severity, ChannelScore, DiagnosisItem, TopVideo } from '../types/report'; + +/** + * API response from generate-report Edge Function. + * The `report` field is AI-generated JSON with varying structure. + */ +interface ApiReport { + clinicInfo?: { + name?: string; + nameEn?: string; + established?: string; + address?: string; + phone?: string; + services?: string[]; + doctors?: { name: string; specialty: string; rating?: number; reviews?: number }[]; + leadDoctor?: { name: string; specialty: string; rating?: number; reviewCount?: number }; + staffCount?: number; + }; + newChannelProposals?: { channel?: string; priority?: string; rationale?: string }[]; + executiveSummary?: string; + overallScore?: number; + channelAnalysis?: Record; + competitors?: { + name: string; + strengths?: string[]; + weaknesses?: string[]; + marketingChannels?: string[]; + }[]; + keywords?: { + primary?: { keyword: string; monthlySearches?: number; competition?: string }[]; + longTail?: { keyword: string; monthlySearches?: number }[]; + }; + targetAudience?: { + primary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; + secondary?: { ageRange?: string; gender?: string; interests?: string[]; channels?: string[] }; + }; + recommendations?: { + priority?: string; + category?: string; + title?: string; + description?: string; + expectedImpact?: string; + }[]; + brandIdentity?: { + area?: string; + asIs?: string; + toBe?: string; + }[]; + kpiTargets?: { + metric?: string; + current?: string; + target3Month?: string; + target12Month?: string; + }[]; + marketTrends?: string[]; +} + +interface ApiMetadata { + url: string; + clinicName: string; + generatedAt: string; + dataSources?: Record; + /** 'registry' = clinic_registry DB 검증 경로. 'scrape' = 실시간 탐색 경로. */ + source?: 'registry' | 'scrape'; + /** Registry에서 제공된 병원 메타데이터 */ + registryData?: { + district?: string; + branches?: string; + brandGroup?: string; + websiteEn?: string; + naverPlaceUrl?: string; + gangnamUnniUrl?: string; + googleMapsUrl?: string; + } | null; +} + +function scoreToSeverity(score: number | undefined): Severity { + if (score === undefined) return 'unknown'; + if (score >= 80) return 'excellent'; + if (score >= 60) return 'good'; + if (score >= 40) return 'warning'; + return 'critical'; +} + +function statusToSeverity(status: string | undefined): Severity { + switch (status) { + case 'active': return 'good'; + case 'inactive': return 'critical'; + case 'weak': return 'warning'; + default: return 'unknown'; + } +} + +const CHANNEL_ICONS: Record = { + naverBlog: 'blog', + instagram: 'instagram', + youtube: 'youtube', + naverPlace: 'map', + gangnamUnni: 'star', + website: 'globe', + facebook: 'facebook', + tiktok: 'video', +}; + +function buildChannelScores(channels: ApiReport['channelAnalysis']): ChannelScore[] { + if (!channels) return []; + return Object.entries(channels).map(([key, ch]) => ({ + channel: key, + icon: CHANNEL_ICONS[key] || 'circle', + score: ch.score ?? 0, + maxScore: 100, + status: ch.score !== undefined ? scoreToSeverity(ch.score) : statusToSeverity(ch.status), + headline: ch.recommendation || '', + })); +} + +function buildDiagnosis(report: ApiReport): DiagnosisItem[] { + const items: DiagnosisItem[] = []; + + // Extract issues from channel analysis + if (report.channelAnalysis) { + for (const [channel, ch] of Object.entries(report.channelAnalysis)) { + // AI-generated per-channel diagnosis array (new format) + if (ch.diagnosis) { + for (const d of ch.diagnosis) { + const issue = typeof d === 'string' ? d : typeof d.issue === 'string' ? d.issue : null; + if (issue) { + const rec = typeof d.recommendation === 'string' ? d.recommendation : ''; + items.push({ + category: CHANNEL_ICONS[channel] ? channel : channel, + detail: rec ? `${issue} — ${rec}` : issue, + severity: (d.severity as Severity) || 'warning', + }); + } + } + } + // Fallback: single recommendation (old format) + else if (ch.status === 'inactive' || ch.status === 'weak') { + items.push({ + category: channel, + detail: ch.recommendation || `${channel} 채널이 ${ch.status === 'inactive' ? '비활성' : '약함'} 상태입니다`, + severity: statusToSeverity(ch.status), + }); + } + if (ch.issues) { + for (const issue of ch.issues) { + const issueObj = issue as Record; + const detail = typeof issue === 'string' + ? issue + : typeof issueObj.issue === 'string' + ? issueObj.issue + : null; + if (detail) { + items.push({ category: channel, detail, severity: (issueObj.severity as Severity) || 'warning' }); + } + } + } + } + } + + // Extract from recommendations + if (report.recommendations) { + for (const rec of report.recommendations) { + if (rec.priority === 'high') { + items.push({ + category: rec.category || '일반', + detail: `${rec.title}: ${rec.description}`, + severity: 'critical', + }); + } + } + } + + return items; +} + +function buildTransformation(r: ApiReport): import('../types/report').TransformationProposal { + const channels = r.channelAnalysis || {}; + + // Brand Identity — from AI or generate defaults + const brandIdentity = (r.brandIdentity || []) + .filter((item): item is { area?: string; asIs?: string; toBe?: string } => !!item?.area) + .map(item => ({ area: item.area || '', asIs: item.asIs || '', toBe: item.toBe || '' })); + + if (brandIdentity.length === 0) { + brandIdentity.push( + { area: '로고 사용', asIs: '채널마다 다른 로고/프로필 이미지 사용', toBe: '공식 로고 가이드 기반 전 채널 통일' }, + { area: '컬러 시스템', asIs: '통일된 브랜드 컬러 없음', toBe: '주/보조 컬러 지정 및 전 채널 적용' }, + { area: '톤앤매너', asIs: '채널별 다른 커뮤니케이션 스타일', toBe: '브랜드 보이스 가이드 수립 및 적용' }, + ); + } + + // Content Strategy + const contentStrategy = (r.recommendations || []) + .filter(rec => rec.category?.includes('콘텐츠') || rec.category?.includes('content')) + .map(rec => ({ area: rec.title || '', asIs: rec.description || '', toBe: rec.expectedImpact || '' })); + + if (contentStrategy.length === 0) { + contentStrategy.push( + { area: '콘텐츠 캘린더', asIs: '캘린더 없음, 비정기 업로드', toBe: '주간 콘텐츠 캘린더 운영 (주 5-10건)' }, + { area: '숏폼 전략', asIs: 'Shorts/Reels 미운영 또는 미비', toBe: 'YouTube Shorts + Instagram Reels 크로스 포스팅' }, + { area: '콘텐츠 다변화', asIs: '단일 포맷 위주', toBe: '수술 전문성 / 환자 후기 / 트렌드 / Q&A 4 pillar 운영' }, + ); + } + + // Platform Strategies — rich per-channel + const platformStrategies: import('../types/report').PlatformStrategy[] = []; + + if (channels.youtube) { + const subs = channels.youtube.subscribers ?? 0; + platformStrategies.push({ + platform: 'YouTube', + icon: 'youtube', + currentMetric: subs > 0 ? `${fmt(subs)} 구독자` : '채널 운영 중', + targetMetric: subs > 0 ? `${fmt(Math.round(subs * 2))} 구독자` : '200K 구독자', + strategies: [ + { strategy: 'Shorts 주 3-5회 업로드', detail: '15-60초 숏폼으로 신규 유입 극대화' }, + { strategy: 'Long-form 5-15분 심층 콘텐츠', detail: '상담 연결 → 전환 최적화' }, + { strategy: 'VIEW 골드 버튼워크 + 통합 콘텐츠', detail: '구독자 참여형 커뮤니티 활성화' }, + ], + }); + } + + if (channels.instagram) { + const followers = channels.instagram.followers ?? 0; + platformStrategies.push({ + platform: 'Instagram KR', + icon: 'instagram', + currentMetric: followers > 0 ? `${fmt(followers)} 팔로워, Reels 0개` : '계정 운영 중', + targetMetric: followers > 0 ? `${fmt(Math.round(followers * 3))} 팔로워, Reels 주 5회` : '50K 팔로워', + strategies: [ + { strategy: 'Reels: YouTube Shorts 동시 게시', detail: '1개 소스 → 2개 채널 자동 배포' }, + { strategy: 'Carousel: 시술 가이드 5-7장', detail: '저장/공유 유도형 교육 콘텐츠' }, + { strategy: 'Stories: 일상, 비하인드, 투표', detail: '팔로워 인게이지먼트 강화' }, + ], + }); + } + + if (channels.facebook) { + platformStrategies.push({ + platform: 'Facebook', + icon: 'facebook', + currentMetric: `KR ${fmt(channels.facebook.followers ?? 0)}명`, + targetMetric: '통합 페이지 + 광고 최적화', + strategies: [ + { strategy: 'KR 페이지 + EN 페이지 통합 관리', detail: '중복 페이지 정리 및 역할 분리' }, + { strategy: 'Facebook Pixel 리타겟팅 광고', detail: '웹사이트 방문자 재타겟팅' }, + { strategy: '광고 VIEW 골드로 퍼시 고객 도달', detail: '잠재고객 세그먼트 광고 집행' }, + ], + }); + } + + // Website improvements + const websiteImprovements = (channels.website?.issues || []).map(issue => ({ + area: '웹사이트', + asIs: issue, + toBe: '개선 필요', + })); + + if (websiteImprovements.length === 0) { + websiteImprovements.push( + { area: 'SNS 연동', asIs: '웹사이트에 SNS 링크 없음', toBe: 'YouTube/Instagram 피드 위젯 + 링크 추가' }, + { area: '추적 픽셀', asIs: '광고 추적 미설치', toBe: 'Meta Pixel + Google Analytics 4 설치' }, + { area: '상담 전환', asIs: '전화번호만 노출', toBe: '카카오톡 상담 + 온라인 예약 CTA 추가' }, + ); + } + + // New channel proposals + const newChannelProposals = (r.newChannelProposals || []) + .filter((p): p is { channel?: string; priority?: string; rationale?: string } => !!p?.channel) + .map(p => ({ channel: p.channel || '', priority: p.priority || 'P2', rationale: p.rationale || '' })); + + if (newChannelProposals.length === 0) { + if (!channels.naverBlog) { + newChannelProposals.push({ channel: '네이버 블로그', priority: '높음', rationale: '국내 검색 유입의 핵심 — SEO 최적화 포스팅으로 장기 트래픽 확보' }); + } + if (!channels.tiktok) { + newChannelProposals.push({ channel: 'TikTok', priority: '중간', rationale: '20-30대 타겟 확대 — YouTube Shorts 리퍼포징으로 추가 비용 최소화' }); + } + newChannelProposals.push({ channel: '카카오톡 채널', priority: '높음', rationale: '상담 전환 직접 채널 — 1:1 상담, 예약 연동, 콘텐츠 푸시' }); + } + + return { brandIdentity, contentStrategy, platformStrategies, websiteImprovements, newChannelProposals }; +} + +/** Safely format KPI values — handles numbers, strings, and nulls from AI output */ +function fmtKpi(v: unknown): string { + if (v == null) return '-'; + if (typeof v === 'number') return fmt(v); + return String(v); +} + +function fmt(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return n.toLocaleString(); +} + +function buildRoadmap(r: ApiReport): import('../types/report').RoadmapMonth[] { + const highRecs = (r.recommendations || []).filter(rec => rec.priority === 'high'); + const medRecs = (r.recommendations || []).filter(rec => rec.priority === 'medium'); + const lowRecs = (r.recommendations || []).filter(rec => rec.priority === 'low'); + + const channels = r.channelAnalysis || {}; + const hasYT = !!channels.youtube; + const hasIG = !!channels.instagram; + const hasFB = !!channels.facebook; + const hasNaver = !!channels.naverBlog; + + // Month 1: Foundation — brand & infrastructure + const m1Tasks = [ + '브랜드 아이덴티티 가이드 통합 (로고, 컬러, 폰트, 톤앤매너)', + '전 채널 프로필 사진/커버 통일 교체', + ...(hasFB ? ['Facebook KR 페이지 정리 (통합 또는 폐쇄)'] : []), + ...(hasIG ? [`Instagram KR 팔로잉 정리 (${fmt(channels.instagram?.followers ?? 0)} → 최적화)`] : []), + '웹사이트에 YouTube/Instagram 링크 추가', + ...(hasYT ? ['기존 YouTube 인기 영상 100개 → AI 숏폼 추출 시작'] : []), + '콘텐츠 캘린더 v1 수립', + ...highRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), + ].filter(Boolean).slice(0, 8); + + // Month 2: Content Engine — production & distribution + const m2Tasks = [ + ...(hasYT ? ['YouTube Shorts 주 3~5회 업로드 시작'] : []), + ...(hasIG ? ['Instagram Reels 주 5회 업로드 시작'] : []), + '검색 결과 쌓을 2차 콘텐츠 스케줄 운영', + ...(hasNaver ? ['네이버 블로그 2,000자 이상 SEO 최적화 포스트'] : []), + '"리얼 상담실" 시리즈 4회 제작/업로드', + '숏폼 콘텐츠 파이프라인 자동화', + ...medRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), + ].filter(Boolean).slice(0, 7); + + // Month 3: Optimization — performance & scaling + const m3Tasks = [ + '전 채널 복합 지표 리뷰 v1', + ...(hasIG ? ['Instagram/Facebook 통합 콘텐츠 배포 체계'] : []), + ...(hasYT ? ['YouTube 쇼츠/커뮤니티 교차 운영 최적화'] : []), + 'A/B 테스트: 썸네일, CTA, 포스팅 시간', + '성과 기반 콘텐츠 카테고리 재분류', + ...lowRecs.slice(0, 2).map(rec => rec.title || rec.description || ''), + ].filter(Boolean).slice(0, 6); + + return [ + { month: 1, title: 'Foundation', subtitle: '기반 구축', tasks: m1Tasks.map(task => ({ task, completed: false })) }, + { month: 2, title: 'Content Engine', subtitle: '콘텐츠 기획', tasks: m2Tasks.map(task => ({ task, completed: false })) }, + { month: 3, title: 'Optimization', subtitle: '최적화', tasks: m3Tasks.map(task => ({ task, completed: false })) }, + ]; +} + +function buildKpiDashboard(r: ApiReport): import('../types/report').KPIMetric[] { + // Always build comprehensive KPIs from channel data. + // Prefer real enrichment data over AI-guessed channelAnalysis. + const channels = r.channelAnalysis || {}; + const enrichment = (r as Record).channelEnrichment as Record | undefined; + const metrics: import('../types/report').KPIMetric[] = []; + + // YouTube metrics — prefer enrichment (real API data) over AI guess + const ytEnrich = enrichment?.youtube as Record | undefined; + const ytSubs = (ytEnrich?.subscribers as number) || ((channels.youtube?.subscribers as number) ?? 0); + const ytViews = (ytEnrich?.totalViews as number) || 0; + const ytVideos = (ytEnrich?.totalVideos as number) || 0; + + if (channels.youtube || ytEnrich) { + metrics.push({ + metric: 'YouTube 구독자', + current: ytSubs > 0 ? fmt(ytSubs) : '-', + target3Month: ytSubs > 0 ? fmt(Math.round(ytSubs * 1.1)) : '10K', + target12Month: ytSubs > 0 ? fmt(Math.round(ytSubs * 2)) : '50K', + }); + const monthlyViews = ytViews > 0 && ytVideos > 0 ? Math.round(ytViews / Math.max(ytVideos / 12, 1)) : 0; + metrics.push({ + metric: 'YouTube 월 조회수', + current: monthlyViews > 0 ? `~${fmt(monthlyViews)}` : '-', + target3Month: monthlyViews > 0 ? fmt(Math.round(monthlyViews * 2)) : '500K', + target12Month: monthlyViews > 0 ? fmt(Math.round(monthlyViews * 5)) : '1.5M', + }); + metrics.push({ + metric: 'YouTube Shorts 평균 조회수', + current: '500~1,000', + target3Month: '5,000', + target12Month: '20,000', + }); + } + + // Instagram metrics — prefer enrichment data + const igAccounts = (enrichment?.instagramAccounts as Record[]) || []; + const igPrimary = igAccounts[0] || (enrichment?.instagram as Record) || null; + const igSecondary = igAccounts[1] || null; + const igFollowers = (igPrimary?.followers as number) || ((channels.instagram?.followers as number) ?? 0); + + if (channels.instagram || igPrimary) { + metrics.push({ + metric: 'Instagram KR 팔로워', + current: igFollowers > 0 ? fmt(igFollowers) : '-', + target3Month: igFollowers > 0 ? fmt(Math.round(igFollowers * 1.4)) : '20K', + target12Month: igFollowers > 0 ? fmt(Math.round(igFollowers * 3.5)) : '50K', + }); + // KR Reels: igtvVideoCount로 운영 여부 판단, 없으면 측정 불가로 표시 + const krIgAny = igPrimary as Record | null; + const krReels = krIgAny ? ((krIgAny.igtvVideoCount as number) ?? -1) : -1; + metrics.push({ + metric: 'Instagram KR Reels 평균 조회수', + current: krReels > 0 ? `${krReels}개 운영 중 (조회수 측정 불가)` : krReels === 0 ? '0 (미운영)' : '측정 불가', + target3Month: '3,000', + target12Month: '10,000', + }); + if (igSecondary) { + const enFollowers = (igSecondary.followers as number) || 0; + metrics.push({ + metric: 'Instagram EN 팔로워', + current: enFollowers > 0 ? fmt(enFollowers) : '-', + target3Month: enFollowers > 0 ? fmt(Math.round(enFollowers * 1.1)) : '75K', + target12Month: enFollowers > 0 ? fmt(Math.round(enFollowers * 1.5)) : '100K', + }); + } + } + + // Naver Blog — 방문자수는 블로그 소유자만 확인 가능 (비공개), 측정 불가로 표시 + if (channels.naverBlog) { + const blogStatus = r.channelAnalysis?.naverBlog?.status; + const blogPosts = r.channelAnalysis?.naverBlog?.posts; + const blogCurrent = blogStatus === 'active' && blogPosts + ? `검색 노출 ${fmt(blogPosts)}건 (방문자 비공개)` + : '측정 불가'; + metrics.push({ + metric: '네이버 블로그 방문자', + current: blogCurrent, + target3Month: '5,000/월', + target12Month: '30,000/월', + }); + } + + // 강남언니 — prefer enrichment data + const guEnrich = enrichment?.gangnamUnni as Record | undefined; + const guRating = (guEnrich?.rating as number) || ((channels.gangnamUnni?.rating as number) ?? 0); + const guCorrected = typeof guRating === 'number' && guRating > 0 && guRating <= 5 ? guRating * 2 : guRating; + const guReviews = (guEnrich?.totalReviews as number) || ((channels.gangnamUnni?.reviews as number) ?? 0); + + if (channels.gangnamUnni || guEnrich) { + metrics.push({ + metric: '강남언니 평점', + current: guCorrected > 0 ? `${guCorrected}/10` : '-', + target3Month: guCorrected > 0 ? `${Math.min(guCorrected + 0.5, 10).toFixed(1)}/10` : '8.0/10', + target12Month: guCorrected > 0 ? `${Math.min(guCorrected + 1.0, 10).toFixed(1)}/10` : '9.0/10', + }); + if (guReviews > 0) { + metrics.push({ + metric: '강남언니 리뷰 수', + current: fmt(guReviews), + target3Month: fmt(Math.round(guReviews * 1.15)), + target12Month: fmt(Math.round(guReviews * 1.5)), + }); + } + } + + // 네이버 플레이스 평점 — 목표가 현재보다 낮으면 유지/개선으로 동적 설정 + if (channels.naverPlace) { + const npRating = channels.naverPlace.rating ?? 0; + const npCurrent = npRating ? `${npRating}/5` : '-'; + const np3mo = npRating >= 4.8 ? `${npRating}/5 유지` : '4.8/5'; + const np12mo = npRating >= 4.9 ? '5.0/5' : '4.9/5'; + metrics.push({ + metric: '네이버 플레이스 평점', + current: npCurrent, + target3Month: np3mo, + target12Month: np12mo, + }); + } + + // Cross-platform — 트래킹 픽셀 미설치 시 측정 불가 + const hasTracking = r.channelAnalysis?.website?.trackingPixels && (r.channelAnalysis.website.trackingPixels as unknown[]).length > 0; + metrics.push({ + metric: '웹사이트 + SNS 유입', + current: hasTracking ? '측정 중' : '측정 불가 (트래킹 미설치)', + target3Month: '5%', + target12Month: '15%', + }); + + metrics.push({ + metric: '콘텐츠 → 상담 전환', + current: '측정 불가', + target3Month: 'UTM 추적 시작', + target12Month: '월 50건', + }); + + // Merge AI-provided KPIs that we didn't already cover + if (r.kpiTargets?.length) { + const existingNames = new Set(metrics.map(m => m.metric.toLowerCase())); + for (const k of r.kpiTargets) { + if (!k?.metric) continue; + // Skip if we already have a similar metric + const lower = k.metric.toLowerCase(); + if (existingNames.has(lower)) continue; + if (lower.includes('youtube') && existingNames.has('youtube 구독자')) continue; + if (lower.includes('instagram') && existingNames.has('instagram kr 팔로워')) continue; + if (lower.includes('강남언니') || lower.includes('gangnam')) { + // Use AI's gangnamunni data — update existing or add + const guIdx = metrics.findIndex(m => m.metric.includes('강남언니')); + if (guIdx >= 0) { + metrics[guIdx] = { metric: k.metric, current: fmtKpi(k.current), target3Month: fmtKpi(k.target3Month), target12Month: fmtKpi(k.target12Month) }; + continue; + } + } + metrics.push({ + metric: k.metric, + current: fmtKpi(k.current), + target3Month: fmtKpi(k.target3Month), + target12Month: fmtKpi(k.target12Month), + }); + } + } + + return metrics; +} + +/** + * Transform raw API response into the MarketingReport shape + * that frontend components expect. + */ +export function transformApiReport( + reportId: string, + apiReport: ApiReport, + metadata: ApiMetadata, +): MarketingReport { + const r = apiReport; + const clinic = r.clinicInfo || {}; + const doctor = clinic.leadDoctor || clinic.doctors?.[0]; + + return { + id: reportId, + createdAt: metadata.generatedAt || new Date().toISOString(), + targetUrl: metadata.url, + overallScore: r.overallScore ?? 50, + + clinicSnapshot: { + name: clinic.name || metadata.clinicName || '', + nameEn: clinic.nameEn || '', + // Registry foundedYear takes priority over AI-generated value (Registry = human-verified) + established: clinic.established || '', + yearsInBusiness: clinic.established ? new Date().getFullYear() - parseInt(clinic.established) : 0, + staffCount: typeof clinic.staffCount === 'number' ? clinic.staffCount : (clinic.doctors?.length ?? 0), + leadDoctor: { + name: doctor?.name || '', + credentials: doctor?.specialty || '', + rating: doctor?.rating ?? 0, + reviewCount: (doctor as { reviewCount?: number })?.reviewCount ?? (doctor as { reviews?: number })?.reviews ?? 0, + }, + // 강남언니 is 10-point scale. AI sometimes gives 5-point — auto-correct. + overallRating: (() => { + const raw = r.channelAnalysis?.gangnamUnni?.rating ?? 0; + return typeof raw === 'number' && raw > 0 && raw <= 5 ? raw * 2 : raw; + })(), + totalReviews: r.channelAnalysis?.gangnamUnni?.reviews ?? 0, + priceRange: { min: '-', max: '-', currency: '₩' }, + certifications: [], + mediaAppearances: [], + medicalTourism: [], + location: clinic.address || '', + nearestStation: '', + phone: clinic.phone || '', + domain: (() => { try { return new URL(metadata.url || '').hostname; } catch { return metadata.url || ''; } })(), + // Registry-sourced fields + source: metadata.source ?? 'scrape', + registryData: metadata.registryData ?? undefined, + }, + + channelScores: buildChannelScores(r.channelAnalysis), + + youtubeAudit: { + channelName: clinic.name || '', + handle: '', + subscribers: r.channelAnalysis?.youtube?.subscribers ?? 0, + totalVideos: 0, + totalViews: 0, + weeklyViewGrowth: { absolute: 0, percentage: 0 }, + estimatedMonthlyRevenue: { min: 0, max: 0 }, + avgVideoLength: '-', + uploadFrequency: '-', + channelCreatedDate: '', + subscriberRank: '-', + channelDescription: '', + linkedUrls: [], + playlists: [], + topVideos: [], + diagnosis: (r.channelAnalysis?.youtube?.recommendation) + ? [{ category: 'YouTube', detail: r.channelAnalysis.youtube.recommendation, severity: scoreToSeverity(r.channelAnalysis.youtube.score) }] + : [], + }, + + instagramAudit: { + accounts: r.channelAnalysis?.instagram ? [{ + handle: '', + language: 'KR', + label: '메인', + posts: r.channelAnalysis.instagram.posts ?? 0, + followers: r.channelAnalysis.instagram.followers ?? 0, + following: 0, + category: '의료/건강', + profileLink: '', + highlights: [], + reelsCount: 0, + contentFormat: '', + profilePhoto: '', + bio: '', + }] : [], + diagnosis: (r.channelAnalysis?.instagram?.recommendation) + ? [{ category: 'Instagram', detail: r.channelAnalysis.instagram.recommendation, severity: scoreToSeverity(r.channelAnalysis.instagram.score) }] + : [], + }, + + facebookAudit: { + pages: [], + diagnosis: [], + brandInconsistencies: [], + consolidationRecommendation: '', + }, + + otherChannels: [ + ...(r.channelAnalysis?.naverBlog ? [{ + name: '네이버 블로그', + status: (r.channelAnalysis.naverBlog.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', + details: r.channelAnalysis.naverBlog.recommendation || '', + }] : []), + ...(r.channelAnalysis?.naverPlace ? [{ + name: '네이버 플레이스', + status: (r.channelAnalysis.naverPlace.status === 'active' ? 'active' : 'inactive') as 'active' | 'inactive', + details: `평점: ${r.channelAnalysis.naverPlace.rating ?? '-'} / 리뷰: ${r.channelAnalysis.naverPlace.reviews ?? '-'}`, + }] : []), + ...(r.channelAnalysis?.gangnamUnni ? [{ + name: '강남언니', + status: (r.channelAnalysis.gangnamUnni.status === 'active' || r.channelAnalysis.gangnamUnni.rating ? 'active' : 'inactive') as 'active' | 'inactive', + details: (() => { + const raw = r.channelAnalysis?.gangnamUnni?.rating; + const rating = typeof raw === 'number' && raw > 0 && raw <= 5 ? raw * 2 : raw; + return `평점: ${rating ?? '-'}/10 / 리뷰: ${r.channelAnalysis?.gangnamUnni?.reviews ?? '-'}`; + })(), + }] : []), + ], + + websiteAudit: { + primaryDomain: (() => { try { return new URL(metadata.url || '').hostname; } catch { return metadata.url || ''; } })(), + additionalDomains: (r.channelAnalysis?.website?.additionalDomains || []).map(d => ({ + domain: (d as { domain?: string }).domain || '', + purpose: (d as { purpose?: string }).purpose || '', + })), + snsLinksOnSite: r.channelAnalysis?.website?.snsLinksOnSite ?? false, + trackingPixels: (r.channelAnalysis?.website?.trackingPixels || []).map(p => ({ + name: (p as { name?: string }).name || '', + installed: (p as { installed?: boolean }).installed ?? false, + })), + mainCTA: r.channelAnalysis?.website?.mainCTA || '', + }, + + problemDiagnosis: buildDiagnosis(r), + + transformation: buildTransformation(r), + + roadmap: buildRoadmap(r), + + kpiDashboard: buildKpiDashboard(r), + + screenshots: [], + }; +} + +/** + * Enrichment data shape from enrich-channels Edge Function. + */ +export interface EnrichmentData { + instagramAccounts?: { + username?: string; + followers?: number; + following?: number; + posts?: number; + bio?: string; + isBusinessAccount?: boolean; + externalUrl?: string; + }[]; + instagram?: { + username?: string; + followers?: number; + following?: number; + posts?: number; + bio?: string; + isBusinessAccount?: boolean; + externalUrl?: string; + latestPosts?: { + type?: string; + likes?: number; + comments?: number; + caption?: string; + timestamp?: string; + }[]; + }; + googleMaps?: { + name?: string; + rating?: number; + reviewCount?: number; + address?: string; + phone?: string; + website?: string; + category?: string; + openingHours?: unknown; + topReviews?: { + stars?: number; + text?: string; + publishedAtDate?: string; + }[]; + }; + youtube?: { + channelId?: string; + channelName?: string; + handle?: string; + description?: string; + publishedAt?: string; + thumbnailUrl?: string; + subscribers?: number; + totalViews?: number; + totalVideos?: number; + videos?: { + title?: string; + views?: number; + likes?: number; + comments?: number; + date?: string; + duration?: string; + url?: string; + thumbnail?: string; + }[]; + }; + gangnamUnni?: { + name?: string; + rating?: number; + ratingScale?: string; + totalReviews?: number; + doctors?: { name?: string; rating?: number; reviews?: number; specialty?: string }[]; + procedures?: string[]; + address?: string; + badges?: string[]; + sourceUrl?: string; + }; + // 스크래핑 시 캡처된 스크린샷 목록 (channel_data.screenshots) + screenshots?: { + id: string; + url: string; + channel: string; + caption: string; + capturedAt?: string; + sourceUrl?: string; + }[]; + naverBlog?: { + totalResults?: number; + searchQuery?: string; + posts?: { + title?: string; + description?: string; + link?: string; + bloggerName?: string; + postDate?: string; + }[]; + }; + naverPlace?: { + name?: string; + category?: string; + address?: string; + telephone?: string; + link?: string; + mapx?: string; + mapy?: string; + }; + facebook?: { + pageName?: string; + pageUrl?: string; + followers?: number; + likes?: number; + categories?: string[]; + email?: string; + phone?: string; + website?: string; + address?: string; + intro?: string; + rating?: number; + profilePictureUrl?: string; + }; +} + +/** + * Generate data-driven diagnosis items from enrichment data. + */ +function generateEnrichmentDiagnosis(enrichment: EnrichmentData): DiagnosisItem[] { + const items: DiagnosisItem[] = []; + + // YouTube diagnosis + if (enrichment.youtube) { + const yt = enrichment.youtube; + const videos = yt.videos || []; + const shorts = videos.filter(v => v.duration && parseInt(v.duration) < 60); + const shortsRatio = videos.length > 0 ? (shorts.length / videos.length) * 100 : 0; + + if (shortsRatio === 0) { + items.push({ category: 'YouTube', detail: 'Shorts 콘텐츠가 없습니다. 숏폼 영상은 신규 유입에 가장 효과적인 포맷입니다.', severity: 'warning' }); + } + if (!yt.description || yt.description.length < 50) { + items.push({ category: 'YouTube', detail: '채널 설명이 미비합니다. SEO와 채널 신뢰도를 위해 키워드 포함 설명을 작성하세요.', severity: 'warning' }); + } + if (yt.totalVideos && yt.totalViews) { + const avgViews = yt.totalViews / yt.totalVideos; + if (avgViews < 500) { + items.push({ category: 'YouTube', detail: `영상당 평균 조회수 ${Math.round(avgViews)}회로 낮습니다. 썸네일과 제목 최적화가 필요합니다.`, severity: 'warning' }); + } + } + if (yt.subscribers && yt.subscribers < 10000) { + items.push({ category: 'YouTube', detail: `구독자 ${yt.subscribers.toLocaleString()}명으로 성장 여지가 큽니다. 일관된 업로드 스케줄을 권장합니다.`, severity: 'good' }); + } + } + + // Instagram diagnosis + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + for (const ig of igAccounts) { + const handle = ig.username || 'Instagram'; + if (!ig.bio) { + items.push({ category: 'Instagram', detail: `@${handle} 바이오가 비어있습니다. CTA 링크와 소개를 추가하세요.`, severity: 'warning' }); + } + if (!ig.externalUrl) { + items.push({ category: 'Instagram', detail: `@${handle} 외부 링크(웹사이트/예약)가 설정되지 않았습니다.`, severity: 'warning' }); + } + if (!ig.isBusinessAccount) { + items.push({ category: 'Instagram', detail: `@${handle} 비즈니스 계정이 아닙니다. 인사이트 분석을 위해 비즈니스 계정 전환을 권장합니다.`, severity: 'critical' }); + } + if (ig.followers && ig.followers > 1000 && ig.posts && ig.posts < 30) { + items.push({ category: 'Instagram', detail: `@${handle} 팔로워 대비 게시물이 적습니다 (${ig.posts}개). 콘텐츠 업로드 빈도를 높이세요.`, severity: 'warning' }); + } + } + + // Facebook diagnosis + if (enrichment.facebook) { + const fb = enrichment.facebook; + if ((fb.followers ?? 0) < 500) { + items.push({ category: 'Facebook', detail: `팔로워 ${fb.followers?.toLocaleString() ?? 0}명으로 페이지 활성화가 필요합니다.`, severity: 'warning' }); + } + if (!fb.intro) { + items.push({ category: 'Facebook', detail: 'Facebook 페이지 소개글이 없습니다. 병원 정보와 CTA를 추가하세요.', severity: 'warning' }); + } + } + + // 강남언니 diagnosis + if (enrichment.gangnamUnni) { + const gu = enrichment.gangnamUnni; + if (gu.rating && gu.rating < 9.0) { + items.push({ category: '강남언니', detail: `평점 ${gu.rating}/10 — 업계 상위권(9.5+) 대비 개선 여지가 있습니다.`, severity: 'warning' }); + } + if (gu.doctors && gu.doctors.length < 3) { + items.push({ category: '강남언니', detail: `등록 전문의 ${gu.doctors.length}명 — 전문의 프로필을 더 등록하면 신뢰도가 높아집니다.`, severity: 'good' }); + } + if (gu.totalReviews && gu.totalReviews > 5000) { + items.push({ category: '강남언니', detail: `리뷰 ${gu.totalReviews.toLocaleString()}건 — 우수한 리뷰 수입니다. 리뷰 관리와 답변에 집중하세요.`, severity: 'excellent' }); + } + } + + // Google Maps diagnosis + if (enrichment.googleMaps) { + const gm = enrichment.googleMaps; + if (gm.rating && gm.rating < 4.5) { + items.push({ category: 'Google Maps', detail: `평점 ${gm.rating}/5 — 부정 리뷰 대응과 만족도 개선이 필요합니다.`, severity: 'warning' }); + } + if (gm.reviewCount && gm.reviewCount < 100) { + items.push({ category: 'Google Maps', detail: `리뷰 ${gm.reviewCount}건 — 더 많은 환자 리뷰를 유도하세요.`, severity: 'warning' }); + } + } + + // Naver diagnosis + if (enrichment.naverBlog) { + if (enrichment.naverBlog.totalResults && enrichment.naverBlog.totalResults < 100) { + items.push({ category: '네이버 블로그', detail: `블로그 검색 노출 ${enrichment.naverBlog.totalResults}건 — SEO 최적화 블로그 포스팅을 늘리세요.`, severity: 'warning' }); + } + } + + return items; +} + +/** + * Merge enrichment data into an existing MarketingReport. + * Returns a new object — does not mutate the original. + */ +export function mergeEnrichment( + report: MarketingReport, + enrichment: EnrichmentData, +): MarketingReport { + const merged = { ...report }; + + // Instagram enrichment — multi-account support + const igAccounts = enrichment.instagramAccounts || (enrichment.instagram ? [enrichment.instagram] : []); + if (igAccounts.length > 0) { + merged.instagramAudit = { + ...merged.instagramAudit, + accounts: igAccounts.map((ig, idx) => { + const igAny = ig as Record; + // Reels count: use igtvVideoCount (Instagram merged IGTV into Reels) or count from latestPosts + const latestPosts = igAny.latestPosts as { type?: string }[] | undefined; + const reelsFromPosts = latestPosts + ? latestPosts.filter(p => p.type === 'Video' || p.type === 'Reel').length + : 0; + const reelsCount = (igAny.igtvVideoCount as number) || reelsFromPosts; + + return { + handle: ig.username || '', + language: (idx === 0 ? 'KR' : 'EN') as 'KR' | 'EN', + label: igAccounts.length === 1 ? '메인' : idx === 0 ? '국내' : `해외 ${idx}`, + posts: ig.posts ?? 0, + followers: ig.followers ?? 0, + following: ig.following ?? 0, + category: '의료/건강', + profileLink: ig.username ? `https://instagram.com/${ig.username}` : '', + highlights: [], + reelsCount, + contentFormat: ig.isBusinessAccount ? '비즈니스 계정' : '일반 계정', + profilePhoto: '', + bio: ig.bio || '', + }; + }), + }; + + // Update KPI with real follower data from first account + const primaryIg = igAccounts[0]; + if (primaryIg?.followers) { + merged.kpiDashboard = merged.kpiDashboard.map(kpi => + kpi.metric.includes('Instagram KR 팔로워') || kpi.metric === 'Instagram 팔로워' + ? { + ...kpi, + current: fmt(primaryIg.followers!), + target3Month: fmt(Math.round(primaryIg.followers! * 1.4)), + target12Month: fmt(Math.round(primaryIg.followers! * 3.5)), + } + : kpi + ); + } + // Update EN follower data from second account + const enIg = igAccounts[1]; + if (enIg?.followers) { + merged.kpiDashboard = merged.kpiDashboard.map(kpi => + kpi.metric.includes('Instagram EN') + ? { + ...kpi, + current: fmt(enIg.followers!), + target3Month: fmt(Math.round(enIg.followers! * 1.1)), + target12Month: fmt(Math.round(enIg.followers! * 1.5)), + } + : kpi + ); + } + } + + // YouTube enrichment (YouTube Data API v3) + if (enrichment.youtube) { + const yt = enrichment.youtube; + const videos = yt.videos || []; + + // Parse ISO 8601 duration (PT1H2M3S) to readable format + const parseDuration = (iso?: string): string => { + if (!iso) return '-'; + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return iso; + const h = match[1] ? `${match[1]}:` : ''; + const m = match[2] || '0'; + const s = (match[3] || '0').padStart(2, '0'); + return h ? `${h}${m.padStart(2, '0')}:${s}` : `${m}:${s}`; + }; + + // Check if video is a Short (< 60 seconds) + const isShort = (iso?: string): boolean => { + if (!iso) return false; + const match = iso.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + if (!match) return false; + const totalSec = (parseInt(match[1] || '0') * 3600) + (parseInt(match[2] || '0') * 60) + parseInt(match[3] || '0'); + return totalSec <= 60; + }; + + merged.youtubeAudit = { + ...merged.youtubeAudit, + channelName: yt.channelName || merged.youtubeAudit.channelName, + handle: yt.handle || merged.youtubeAudit.handle, + subscribers: yt.subscribers ?? merged.youtubeAudit.subscribers, + totalVideos: yt.totalVideos ?? merged.youtubeAudit.totalVideos, + totalViews: yt.totalViews ?? merged.youtubeAudit.totalViews, + channelDescription: yt.description || merged.youtubeAudit.channelDescription, + channelCreatedDate: yt.publishedAt ? new Date(yt.publishedAt).toLocaleDateString('ko-KR') : merged.youtubeAudit.channelCreatedDate, + topVideos: videos.slice(0, 5).map((v): TopVideo => ({ + title: v.title || '', + views: v.views || 0, + uploadedAgo: v.date ? new Date(v.date).toLocaleDateString('ko-KR') : '', + type: isShort(v.duration) ? 'Short' : 'Long', + duration: parseDuration(v.duration), + })), + }; + + // Update KPI with real YouTube data + if (yt.subscribers) { + merged.kpiDashboard = merged.kpiDashboard.map(kpi => { + if (kpi.metric === 'YouTube 구독자') { + return { + ...kpi, + current: fmt(yt.subscribers!), + target3Month: fmt(Math.round(yt.subscribers! * 1.1)), + target12Month: fmt(Math.round(yt.subscribers! * 2)), + }; + } + if (kpi.metric === 'YouTube 월 조회수' && yt.totalViews && yt.totalVideos) { + const monthlyEstimate = Math.round(yt.totalViews / Math.max((yt.totalVideos / 12), 1)); + return { + ...kpi, + current: `~${fmt(monthlyEstimate)}`, + target3Month: fmt(Math.round(monthlyEstimate * 2)), + target12Month: fmt(Math.round(monthlyEstimate * 5)), + }; + } + return kpi; + }); + } + } + + // Google Maps enrichment + if (enrichment.googleMaps) { + const gm = enrichment.googleMaps; + + merged.clinicSnapshot = { + ...merged.clinicSnapshot, + overallRating: gm.rating ?? merged.clinicSnapshot.overallRating, + totalReviews: gm.reviewCount ?? merged.clinicSnapshot.totalReviews, + phone: gm.phone || merged.clinicSnapshot.phone, + location: gm.address || merged.clinicSnapshot.location, + }; + + // Update or add Google Maps to otherChannels + const gmChannelIdx = merged.otherChannels.findIndex(c => c.name === '구글 지도'); + const gmChannel = { + name: '구글 지도', + status: 'active' as const, + details: `평점: ${gm.rating ?? '-'} / 리뷰: ${gm.reviewCount ?? '-'}`, + // Use Maps URL from enrichment if available, fallback to search URL + url: (gm as Record).mapsUrl + ? String((gm as Record).mapsUrl) + : gm.name ? `https://www.google.com/maps/search/${encodeURIComponent(String(gm.name))}` : '', + }; + if (gmChannelIdx >= 0) { + merged.otherChannels[gmChannelIdx] = gmChannel; + } else { + merged.otherChannels = [...merged.otherChannels, gmChannel]; + } + } + + // 강남언니 enrichment + if (enrichment.gangnamUnni) { + const gu = enrichment.gangnamUnni; + + // Update clinic snapshot with real gangnamUnni data + merged.clinicSnapshot = { + ...merged.clinicSnapshot, + overallRating: gu.rating ?? merged.clinicSnapshot.overallRating, + totalReviews: gu.totalReviews ?? merged.clinicSnapshot.totalReviews, + certifications: gu.badges?.length ? gu.badges : merged.clinicSnapshot.certifications, + staffCount: gu.doctors?.length ?? merged.clinicSnapshot.staffCount, // 전문의 수 (강남언니 등록 의사 기준) + }; + + // Extract nearest station from address (Korean station name pattern) + const addressToSearch = gu.address || merged.clinicSnapshot.location; + if (addressToSearch && !merged.clinicSnapshot.nearestStation) { + const stationMatch = addressToSearch.match(/(\S+역)/); + if (stationMatch) { + merged.clinicSnapshot = { ...merged.clinicSnapshot, nearestStation: stationMatch[1] }; + } + } + + // Update lead doctor with gangnamUnni doctor data + if (gu.doctors?.length) { + const topDoctor = gu.doctors[0]; + if (topDoctor?.name) { + merged.clinicSnapshot = { + ...merged.clinicSnapshot, + leadDoctor: { + name: topDoctor.name, + credentials: topDoctor.specialty || merged.clinicSnapshot.leadDoctor.credentials, + rating: topDoctor.rating ?? 0, + reviewCount: topDoctor.reviews ?? 0, + }, + }; + } + } + + // Update gangnamUnni channel in otherChannels + const guChannelIdx = merged.otherChannels.findIndex(c => c.name === '강남언니'); + const guChannel = { + name: '강남언니', + status: 'active' as const, + details: `평점: ${gu.rating ?? '-'}${gu.ratingScale || '/10'} / 리뷰: ${gu.totalReviews?.toLocaleString() ?? '-'}건`, + url: gu.sourceUrl || '', + }; + if (guChannelIdx >= 0) { + merged.otherChannels[guChannelIdx] = guChannel; + } else { + merged.otherChannels = [...merged.otherChannels, guChannel]; + } + } + + // Facebook enrichment + if (enrichment.facebook) { + const fb = enrichment.facebook; + merged.facebookAudit = { + ...merged.facebookAudit, + pages: [{ + url: fb.pageUrl || '', + pageName: fb.pageName || '', + language: 'KR', + label: '메인', + followers: fb.followers ?? 0, + following: 0, + category: fb.categories?.join(', ') || '', + bio: fb.intro || '', + logo: '', + logoDescription: '', + link: fb.website || '', + linkedDomain: fb.website || '', + reviews: (() => { + // Facebook rating 문자열 파싱: "Not yet rated (3 Reviews)" or "4.8 (120 Reviews)" + const m = String(fb.rating || '').match(/\((\d+)\s+Reviews?\)/i); + return m ? parseInt(m[1], 10) : 0; + })(), + recentPostAge: '', + hasWhatsApp: false, + }], + }; + } + + // 네이버 블로그 enrichment + if (enrichment.naverBlog) { + const nb = enrichment.naverBlog; + const nbChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 블로그'); + const nbChannel = { + name: '네이버 블로그', + status: 'active' as const, + details: `검색 결과: ${nb.totalResults?.toLocaleString() ?? '-'}건 / 최근 포스트 ${nb.posts?.length ?? 0}개`, + // Prefer official blog URL from Phase 1, fallback to search URL + url: (nb as Record).officialBlogUrl + ? String((nb as Record).officialBlogUrl) + : nb.searchQuery ? `https://search.naver.com/search.naver?where=blog&query=${encodeURIComponent(String(nb.searchQuery))}` : '', + }; + if (nbChannelIdx >= 0) { + merged.otherChannels[nbChannelIdx] = nbChannel; + } else { + merged.otherChannels = [...merged.otherChannels, nbChannel]; + } + } + + // 네이버 플레이스 enrichment + if (enrichment.naverPlace) { + const np = enrichment.naverPlace; + const npChannelIdx = merged.otherChannels.findIndex(c => c.name === '네이버 플레이스'); + const npChannel = { + name: '네이버 플레이스', + status: 'active' as const, + details: np.category || '', + // np.link is the clinic's own website, NOT Naver Place page + // Use Naver Place search URL instead + url: np.name ? `https://map.naver.com/v5/search/${encodeURIComponent(String(np.name))}` : '', + }; + if (npChannelIdx >= 0) { + merged.otherChannels[npChannelIdx] = npChannel; + } else { + merged.otherChannels = [...merged.otherChannels, npChannel]; + } + + // Update clinic phone/address from Naver Place if available + if (np.telephone) { + merged.clinicSnapshot = { ...merged.clinicSnapshot, phone: np.telephone }; + } + } + + // Generate data-driven diagnosis from enrichment data + const enrichDiagnosis = generateEnrichmentDiagnosis(enrichment); + if (enrichDiagnosis.length > 0) { + merged.problemDiagnosis = [...merged.problemDiagnosis, ...enrichDiagnosis]; + } + + // ── 스크린샷 영구 반영 ────────────────────────────────────────────────────── + // channel_data.screenshots → report.screenshots 로 옮기고, + // 채널별로 diagnosis evidenceIds 자동 연결 + if (enrichment.screenshots?.length) { + const ss = enrichment.screenshots; + + // 1) report.screenshots 세팅 (ScreenshotEvidence 형식으로 변환) + merged.screenshots = ss.map(s => ({ + id: s.id, + url: s.url, + channel: s.channel, + caption: s.caption, + capturedAt: s.capturedAt ?? new Date().toISOString(), + sourceUrl: s.sourceUrl, + })); + + // 2) 채널명 → screenshot IDs 매핑 테이블 생성 + // channel_data의 channel 필드: "YouTube", "웹사이트", "Instagram", "Facebook" 등 + const CHANNEL_ALIAS: Record = { + youtube: ['youtube', 'YouTube', 'yt'], + instagram: ['instagram', 'Instagram', 'ig'], + facebook: ['facebook', 'Facebook', 'fb'], + website: ['웹사이트', 'website', 'Website'], + gangnamUnni: ['강남언니', 'gangnamUnni'], + naverPlace: ['네이버 플레이스', 'naverPlace'], + naverBlog: ['네이버 블로그', 'naverBlog'], + }; + + const channelToIds: Record = {}; + for (const s of ss) { + for (const [key, aliases] of Object.entries(CHANNEL_ALIAS)) { + if (aliases.some(a => s.channel.toLowerCase().includes(a.toLowerCase()))) { + channelToIds[key] = [...(channelToIds[key] ?? []), s.id]; + break; + } + } + } + + // 3) 채널별 audit.diagnosis 배열에 evidenceIds 연결 + // YouTubeAudit / InstagramAudit / FacebookAudit 컴포넌트가 이 필드를 사용함 + const linkIds = (diagItems: import('../types/report').DiagnosisItem[], channelKey: string): import('../types/report').DiagnosisItem[] => { + const ids = channelToIds[channelKey] ?? []; + if (!ids.length) return diagItems; + return diagItems.map(item => ({ ...item, evidenceIds: [...(item.evidenceIds ?? []), ...ids] })); + }; + + if (merged.youtubeAudit?.diagnosis?.length) { + merged.youtubeAudit = { ...merged.youtubeAudit, diagnosis: linkIds(merged.youtubeAudit.diagnosis, 'youtube') }; + } + if (merged.instagramAudit?.diagnosis?.length) { + merged.instagramAudit = { ...merged.instagramAudit, diagnosis: linkIds(merged.instagramAudit.diagnosis, 'instagram') }; + } + if (merged.facebookAudit?.diagnosis?.length) { + merged.facebookAudit = { ...merged.facebookAudit, diagnosis: linkIds(merged.facebookAudit.diagnosis, 'facebook') }; + } + // websiteAudit / 기타 채널은 EvidenceGallery를 직접 받지 않으므로 problemDiagnosis에만 연결 + merged.problemDiagnosis = merged.problemDiagnosis.map(item => { + const catLower = item.category.toLowerCase(); + let ids: string[] = []; + for (const [key, ssIds] of Object.entries(channelToIds)) { + if (catLower.includes(key) || key.includes(catLower)) { + ids = [...ids, ...ssIds]; + } + } + return ids.length > 0 ? { ...item, evidenceIds: ids } : item; + }); + } + // ─────────────────────────────────────────────────────────────────────────── + + return merged; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8e0deb1 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/src/pages/AnalysisLoadingPage.tsx b/src/pages/AnalysisLoadingPage.tsx new file mode 100644 index 0000000..f83b3c2 --- /dev/null +++ b/src/pages/AnalysisLoadingPage.tsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { useAnalysis } from '@/hooks/useAnalysis' + +/** + * AnalysisLoadingPage — status polling screen. + * + * TODO (D4 frontend): port animation/visual from prototype AnalysisLoadingPage.tsx. + * This stub shows raw status; replace with progress bar + step visualization. + */ +export default function AnalysisLoadingPage() { + const { runId } = useParams<{ runId: string }>() + const navigate = useNavigate() + const { status, pollStatus } = useAnalysis() + + useEffect(() => { + if (runId) pollStatus(runId) + }, [runId, pollStatus]) + + useEffect(() => { + if (!status) return + if (status.status === 'complete' || status.status === 'partial') { + navigate(`/report/${runId}`) + } + }, [status, runId, navigate]) + + return ( +
+
+

분석 중

+

{status?.current_step ?? '초기화 중…'}

+ +
+
+
+

+ 상태: {status?.status ?? 'pending'} +

+ + {status?.status === 'error' && ( +

+ 오류 발생: {status.channel_errors._pipeline ?? '알 수 없는 오류'} +

+ )} +
+
+ ) +} diff --git a/src/pages/AnalysisStartPage.tsx b/src/pages/AnalysisStartPage.tsx new file mode 100644 index 0000000..5eb9ac4 --- /dev/null +++ b/src/pages/AnalysisStartPage.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAnalysis } from '@/hooks/useAnalysis' + +/** + * AnalysisStartPage — MVP 핵심 페이지. + * + * 사용자가 병원 URL + 유튜브/IG/FB/네이버블로그/강남언니 핸들을 직접 입력. + * 원본 프로토타입은 URL만 받고 자동 발견했지만, MVP는 핸들 수동 입력. + * + * TODO (D2 frontend): + * - 각 입력 필드에 `POST /api/channels/verify` 실시간 검증 (debounce 500ms) + * - 핸들 정규화 UI (사용자가 URL 전체 붙여넣어도 @handle 추출) + * - 최소 1개 채널 필수 검증 + */ +export default function AnalysisStartPage() { + const navigate = useNavigate() + const { start, error } = useAnalysis() + const [isSubmitting, setIsSubmitting] = useState(false) + const [form, setForm] = useState({ + url: '', + name: '', + youtube: '', + instagram: '', + facebook: '', + naver_blog: '', + gangnam_unni: '', + }) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setIsSubmitting(true) + try { + const runId = await start({ + url: form.url, + channels: { + youtube: form.youtube || undefined, + instagram: form.instagram ? [form.instagram] : [], + facebook: form.facebook || undefined, + naver_blog: form.naver_blog || undefined, + gangnam_unni: form.gangnam_unni || undefined, + }, + }) + navigate(`/analysis/${runId}/loading`) + } catch { + setIsSubmitting(false) + } + } + + return ( +
+
+

병원 분석 시작

+ + + setForm({ ...form, url: e.target.value })} + className="input" + /> + + +

소셜 채널 (최소 1개)

+ + + setForm({ ...form, youtube: e.target.value })} + className="input" + /> + + + + setForm({ ...form, instagram: e.target.value })} + className="input" + /> + + + + setForm({ ...form, facebook: e.target.value })} + className="input" + /> + + + + setForm({ ...form, naver_blog: e.target.value })} + className="input" + /> + + + + setForm({ ...form, gangnam_unni: e.target.value })} + className="input" + /> + + + {error &&

{error.message}

} + + +
+ + +
+ ) +} + +function Field({ + label, + required, + children, +}: { + label: string + required?: boolean + children: React.ReactNode +}) { + return ( + + ) +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx new file mode 100644 index 0000000..23606f5 --- /dev/null +++ b/src/pages/LandingPage.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom' + +/** + * LandingPage — hero + primary CTA. + * + * TODO (D1 frontend): port visual styling from prototype src/pages/Landing*.tsx. + * MVP needs only: headline + "분석 시작하기" button → /analysis/start + */ +export default function LandingPage() { + return ( +
+
+

+ INFINITH +

+

+ AI가 분석하는 병원 마케팅 리포트 +
+ 유튜브 · 인스타그램 · 네이버 · 강남언니까지, 한 번에. +

+ + 분석 시작하기 + +
+
+ ) +} diff --git a/src/pages/MarketingPlanPage.tsx b/src/pages/MarketingPlanPage.tsx new file mode 100644 index 0000000..115b2eb --- /dev/null +++ b/src/pages/MarketingPlanPage.tsx @@ -0,0 +1,18 @@ +import { useParams } from 'react-router-dom' + +/** + * MarketingPlanPage — TODO (D6): implement after plan_service is ready. + * + * Workflow: + * 1. Hook useMarketingPlan(planId) calls GET /api/plans/{planId} + * 2. Port prototype MarketingPlanPage.tsx sections + * 3. Port transformPlan.ts + */ +export default function MarketingPlanPage() { + const { planId } = useParams() + return ( +
+

Plan {planId} — 구현 예정 (D6)

+
+ ) +} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..a389e88 --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom' + +export default function NotFoundPage() { + return ( +
+

404

+

페이지를 찾을 수 없습니다.

+ + 처음으로 + +
+ ) +} diff --git a/src/pages/ReportPage.tsx b/src/pages/ReportPage.tsx new file mode 100644 index 0000000..5c8b70e --- /dev/null +++ b/src/pages/ReportPage.tsx @@ -0,0 +1,31 @@ +import { useParams } from 'react-router-dom' +import { useReport } from '@/hooks/useReport' + +/** + * ReportPage — display AI-generated marketing report. + * + * TODO (D5 frontend): + * - Copy prototype src/components/report/*.tsx into infinith-web/src/components/report/ + * - Compose them here (ReportHeader, YouTubeAudit, InstagramAudit, etc.) + * - Integrate transformReport.ts after porting + */ +export default function ReportPage() { + const { runId } = useParams<{ runId: string }>() + const { data, isLoading, error } = useReport(runId) + + if (isLoading) return
리포트 로딩 중…
+ if (error) return
{error.message}
+ if (!data) return null + + return ( +
+

{data.clinic?.name ?? 'Unknown'} 분석 리포트

+

종합 점수: {data.overall_score}

+ + {/* TODO(D5): , , , ... */} +
+        {JSON.stringify(data, null, 2)}
+      
+
+ ) +} diff --git a/src/types/plan.ts b/src/types/plan.ts new file mode 100644 index 0000000..7a1275b --- /dev/null +++ b/src/types/plan.ts @@ -0,0 +1,230 @@ +import type { BrandInconsistency } from './report'; + +// ─── Section 1: Branding Guide ─── + +export interface ColorSwatch { + name: string; + hex: string; + usage: string; +} + +export interface FontSpec { + family: string; + weight: string; + usage: string; + sampleText: string; +} + +export interface LogoUsageRule { + rule: string; + description: string; + correct: boolean; +} + +export interface ToneOfVoice { + personality: string[]; + communicationStyle: string; + doExamples: string[]; + dontExamples: string[]; +} + +export interface ChannelBrandingRule { + channel: string; + icon: string; + profilePhoto: string; + bannerSpec: string; + bioTemplate: string; + currentStatus: 'correct' | 'incorrect' | 'missing'; +} + +export interface BrandGuide { + colors: ColorSwatch[]; + fonts: FontSpec[]; + logoRules: LogoUsageRule[]; + toneOfVoice: ToneOfVoice; + channelBranding: ChannelBrandingRule[]; + brandInconsistencies: BrandInconsistency[]; +} + +// ─── Section 2: Channel Communication Strategy ─── + +export interface ChannelStrategyCard { + channelId: string; + channelName: string; + icon: string; + currentStatus: string; + targetGoal: string; + contentTypes: string[]; + postingFrequency: string; + tone: string; + formatGuidelines: string[]; + priority: 'P0' | 'P1' | 'P2'; + customerJourneyStage?: 'awareness' | 'interest' | 'consideration' | 'conversion' | 'loyalty'; +} + +// ─── Section 3: Content Marketing Strategy ─── + +export interface ContentPillar { + title: string; + description: string; + relatedUSP: string; + exampleTopics: string[]; + color: string; +} + +export interface ContentTypeRow { + format: string; + channels: string[]; + frequency: string; + purpose: string; +} + +export interface WorkflowStep { + step: number; + name: string; + description: string; + owner: string; + duration: string; +} + +export interface RepurposingOutput { + format: string; + channel: string; + description: string; +} + +export interface ContentStrategyData { + pillars: ContentPillar[]; + typeMatrix: ContentTypeRow[]; + workflow: WorkflowStep[]; + repurposingSource: string; + repurposingOutputs: RepurposingOutput[]; +} + +// ─── Section 4: Content Calendar ─── + +export type ContentCategory = 'video' | 'blog' | 'social' | 'ad'; + +export interface CalendarEntry { + dayOfWeek: number; + channel: string; + channelIcon: string; + contentType: ContentCategory; + title: string; + // AI-generated fields (optional — backward compatible with deterministic engine) + id?: string; + description?: string; + pillar?: string; + status?: 'draft' | 'approved' | 'published'; + isManualEdit?: boolean; + aiPromptSeed?: string; +} + +export interface CalendarWeek { + weekNumber: number; + label: string; + entries: CalendarEntry[]; +} + +export interface ContentCountSummary { + type: ContentCategory; + label: string; + count: number; + color: string; +} + +export interface CalendarData { + weeks: CalendarWeek[]; + monthlySummary: ContentCountSummary[]; +} + +// ─── Section 5: Asset Collection ─── + +export type AssetSource = 'homepage' | 'naver_place' | 'blog' | 'social' | 'youtube'; +export type AssetType = 'photo' | 'video' | 'text'; +export type AssetStatus = 'collected' | 'pending' | 'needs_creation'; + +export interface AssetCard { + id: string; + source: AssetSource; + sourceLabel: string; + type: AssetType; + title: string; + description: string; + repurposingSuggestions: string[]; + status: AssetStatus; +} + +export interface YouTubeRepurposeItem { + title: string; + views: number; + type: 'Short' | 'Long'; + repurposeAs: string[]; +} + +export interface AssetCollectionData { + assets: AssetCard[]; + youtubeRepurpose: YouTubeRepurposeItem[]; +} + +// ─── Section 6: Repurposing Proposal ─── + +export interface RepurposingProposalItem { + sourceVideo: YouTubeRepurposeItem; + outputs: RepurposingOutput[]; + estimatedEffort: 'low' | 'medium' | 'high'; + priority: 'high' | 'medium' | 'low'; +} + +// ─── Section 7: Workflow Tracker ─── + +export type WorkflowStage = 'planning' | 'ai-draft' | 'review' | 'approved' | 'scheduled'; +export type WorkflowContentType = 'video' | 'image-text'; + +export interface WorkflowVideoDraft { + script: string; + shootingGuide: string[]; + duration: string; // e.g. '60초', '15분' +} + +export interface WorkflowImageTextDraft { + type: 'cardnews' | 'blog'; + headline: string; + copy: string[]; + layoutHint?: string; +} + +export interface WorkflowItem { + id: string; + title: string; + contentType: WorkflowContentType; + channel: string; + channelIcon: string; + stage: WorkflowStage; + userNotes?: string; // 사람이 입력한 수정 사항 + videoDraft?: WorkflowVideoDraft; + imageTextDraft?: WorkflowImageTextDraft; + scheduledDate?: string; +} + +export interface WorkflowData { + items: WorkflowItem[]; +} + +// ─── Root Plan Type ─── + +export interface MarketingPlan { + id: string; + reportId: string; + clinicName: string; + clinicNameEn: string; + createdAt: string; + targetUrl: string; + brandGuide: BrandGuide; + channelStrategies: ChannelStrategyCard[]; + contentStrategy: ContentStrategyData; + calendar: CalendarData; + assetCollection: AssetCollectionData; + repurposingProposals?: RepurposingProposalItem[]; + workflow?: WorkflowData; +} diff --git a/src/types/report.ts b/src/types/report.ts new file mode 100644 index 0000000..bd2868f --- /dev/null +++ b/src/types/report.ts @@ -0,0 +1,243 @@ +export type Severity = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown'; + +export interface ScreenshotAnnotation { + type: 'highlight' | 'arrow' | 'text'; + x: number; + y: number; + width?: number; + height?: number; + label?: string; + color?: string; +} + +export interface ScreenshotEvidence { + id: string; + url: string; + channel: string; + capturedAt: string; + caption: string; + sourceUrl?: string; + annotations?: ScreenshotAnnotation[]; +} + +export interface DiagnosisItem { + category: string; + detail: string; + severity: Severity; + evidenceIds?: string[]; +} + +export interface ClinicSnapshot { + name: string; + nameEn: string; + established: string; + yearsInBusiness: number; + staffCount: number; + leadDoctor: { + name: string; + credentials: string; + rating: number; + reviewCount: number; + }; + overallRating: number; + totalReviews: number; + priceRange: { min: string; max: string; currency: string }; + certifications: string[]; + mediaAppearances: string[]; + medicalTourism: string[]; + location: string; + nearestStation: string; + phone: string; + domain: string; + logoImages?: { + circle?: string; + horizontal?: string; + korean?: string; + }; + brandColors?: { + primary: string; + accent: string; + text: string; + }; + /** 'registry' = Registry DB에서 검증된 채널로 분석. 'scrape' = 홈페이지 실시간 탐색. */ + source?: 'registry' | 'scrape'; + /** Registry에서 가져온 병원 메타데이터 */ + registryData?: { + district?: string; // 지역구 (강남, 압구정, 서초, 역삼) + branches?: string; // 분점 정보 + brandGroup?: string; // 분류 (프리미엄/하이타깃 후보) + websiteEn?: string; // 영문 사이트 URL + naverPlaceUrl?: string; + gangnamUnniUrl?: string; + googleMapsUrl?: string; + }; +} + +export interface TopVideo { + title: string; + views: number; + uploadedAgo: string; + type: 'Short' | 'Long'; + duration?: string; +} + +export interface YouTubeAudit { + channelName: string; + handle: string; + subscribers: number; + totalVideos: number; + totalViews: number; + weeklyViewGrowth: { absolute: number; percentage: number }; + estimatedMonthlyRevenue: { min: number; max: number }; + avgVideoLength: string; + uploadFrequency: string; + channelCreatedDate: string; + subscriberRank: string; + channelDescription: string; + linkedUrls: { label: string; url: string }[]; + playlists: string[]; + topVideos: TopVideo[]; + diagnosis: DiagnosisItem[]; +} + +export interface InstagramAccount { + handle: string; + language: 'KR' | 'EN'; + label: string; + posts: number; + followers: number; + following: number; + category: string; + profileLink: string; + highlights: string[]; + reelsCount: number; + contentFormat: string; + profilePhoto: string; + bio: string; +} + +export interface InstagramAudit { + accounts: InstagramAccount[]; + diagnosis: DiagnosisItem[]; +} + +export interface BrandInconsistency { + field: string; + values: { channel: string; value: string; isCorrect: boolean }[]; + impact: string; + recommendation: string; +} + +export interface FacebookPage { + url: string; + pageName: string; + language: 'KR' | 'EN'; + label: string; + followers: number; + following: number; + category: string; + bio: string; + logo: string; + logoDescription: string; + link: string; + linkedDomain: string; + reviews: number; + recentPostAge: string; + hasWhatsApp: boolean; + postFrequency?: string; + topContentType?: string; + engagement?: string; +} + +export interface FacebookAudit { + pages: FacebookPage[]; + diagnosis: DiagnosisItem[]; + brandInconsistencies: BrandInconsistency[]; + consolidationRecommendation: string; +} + +export interface OtherChannel { + name: string; + status: 'active' | 'inactive' | 'unknown' | 'not_found'; + details: string; + url?: string; +} + +export interface TrackingPixel { + name: string; + installed: boolean; + details?: string; +} + +export interface WebsiteAudit { + primaryDomain: string; + additionalDomains: { domain: string; purpose: string }[]; + snsLinksOnSite: boolean; + snsLinksDetail?: { platform: string; url: string; location: string }[]; + trackingPixels: TrackingPixel[]; + mainCTA: string; +} + +export interface AsIsToBeItem { + area: string; + asIs: string; + toBe: string; +} + +export interface PlatformStrategy { + platform: string; + icon: string; + currentMetric: string; + targetMetric: string; + strategies: { strategy: string; detail: string }[]; +} + +export interface TransformationProposal { + brandIdentity: AsIsToBeItem[]; + contentStrategy: AsIsToBeItem[]; + platformStrategies: PlatformStrategy[]; + websiteImprovements: AsIsToBeItem[]; + newChannelProposals: { channel: string; priority: string; rationale: string }[]; +} + +export interface RoadmapMonth { + month: number; + title: string; + subtitle: string; + tasks: { task: string; completed: boolean }[]; +} + +export interface KPIMetric { + metric: string; + current: string; + target3Month: string; + target12Month: string; +} + +export interface ChannelScore { + channel: string; + icon: string; + score: number; + maxScore: number; + status: Severity; + headline: string; +} + +export interface MarketingReport { + id: string; + createdAt: string; + targetUrl: string; + overallScore: number; + clinicSnapshot: ClinicSnapshot; + channelScores: ChannelScore[]; + youtubeAudit: YouTubeAudit; + instagramAudit: InstagramAudit; + facebookAudit: FacebookAudit; + otherChannels: OtherChannel[]; + websiteAudit: WebsiteAudit; + problemDiagnosis: DiagnosisItem[]; + transformation: TransformationProposal; + roadmap: RoadmapMonth[]; + kpiDashboard: KPIMetric[]; + screenshots?: ScreenshotEvidence[]; +} diff --git a/src/types/studio.ts b/src/types/studio.ts new file mode 100644 index 0000000..ba01969 --- /dev/null +++ b/src/types/studio.ts @@ -0,0 +1,126 @@ +// ─── Content Creation Studio Types ─── + +export type StudioChannel = 'youtube' | 'instagram' | 'naver_blog' | 'tiktok' | 'facebook'; + +export type ContentFormat = + | 'shorts' | 'long_form' // YouTube + | 'reels' | 'carousel' | 'feed_image' | 'stories' // Instagram + | 'seo_post' | 'faq_post' // Naver Blog + | 'short_video' // TikTok + | 'ad_creative' | 'retarget_content'; // Facebook + +export interface ChannelFormatOption { + channel: StudioChannel; + label: string; + icon: string; + formats: { key: ContentFormat; label: string; aspectRatio: '16:9' | '9:16' | '1:1' | '4:5' }[]; +} + +export type ContentPillarId = string; + +export type AssetSourceType = 'collected' | 'my_assets' | 'ai_generated'; + +export type MusicGenre = 'calm' | 'upbeat' | 'cinematic' | 'corporate' | 'none'; + +export interface MusicTrack { + id: string; + name: string; + genre: MusicGenre; + duration: string; +} + +export type NarrationLanguage = 'ko' | 'en' | 'ja' | 'zh'; +export type NarrationVoice = 'male' | 'female'; + +export interface SoundSettings { + genre: MusicGenre; + trackId: string | null; + narrationEnabled: boolean; + narrationLanguage: NarrationLanguage; + narrationVoice: NarrationVoice; + subtitleEnabled: boolean; +} + +export type GenerateOutputType = 'image' | 'video'; + +export interface StudioState { + channel: StudioChannel | null; + format: ContentFormat | null; + pillarId: ContentPillarId | null; + assetSources: AssetSourceType[]; + sound: SoundSettings; + outputType: GenerateOutputType; +} + +export const DEFAULT_SOUND: SoundSettings = { + genre: 'calm', + trackId: null, + narrationEnabled: false, + narrationLanguage: 'ko', + narrationVoice: 'female', + subtitleEnabled: true, +}; + +export const CHANNEL_OPTIONS: ChannelFormatOption[] = [ + { + channel: 'youtube', + label: 'YouTube', + icon: 'youtube', + formats: [ + { key: 'shorts', label: 'Shorts', aspectRatio: '9:16' }, + { key: 'long_form', label: 'Long-form', aspectRatio: '16:9' }, + ], + }, + { + channel: 'instagram', + label: 'Instagram', + icon: 'instagram', + formats: [ + { key: 'reels', label: 'Reels', aspectRatio: '9:16' }, + { key: 'carousel', label: 'Carousel', aspectRatio: '1:1' }, + { key: 'feed_image', label: 'Feed Image', aspectRatio: '1:1' }, + { key: 'stories', label: 'Stories', aspectRatio: '9:16' }, + ], + }, + { + channel: 'naver_blog', + label: 'Naver Blog', + icon: 'globe', + formats: [ + { key: 'seo_post', label: 'SEO Post', aspectRatio: '16:9' }, + { key: 'faq_post', label: 'FAQ Post', aspectRatio: '16:9' }, + ], + }, + { + channel: 'tiktok', + label: 'TikTok', + icon: 'tiktok', + formats: [ + { key: 'short_video', label: 'Short Video', aspectRatio: '9:16' }, + ], + }, + { + channel: 'facebook', + label: 'Facebook', + icon: 'facebook', + formats: [ + { key: 'ad_creative', label: 'Ad Creative', aspectRatio: '1:1' }, + { key: 'retarget_content', label: 'Retarget Content', aspectRatio: '16:9' }, + ], + }, +]; + +export const MUSIC_TRACKS: MusicTrack[] = [ + { id: 'calm-1', name: 'Gentle Morning', genre: 'calm', duration: '2:30' }, + { id: 'calm-2', name: 'Soft Healing', genre: 'calm', duration: '3:15' }, + { id: 'calm-3', name: 'Peaceful Flow', genre: 'calm', duration: '2:45' }, + { id: 'upbeat-1', name: 'Fresh Start', genre: 'upbeat', duration: '2:20' }, + { id: 'upbeat-2', name: 'Bright Day', genre: 'upbeat', duration: '2:50' }, + { id: 'upbeat-3', name: 'Energy Boost', genre: 'upbeat', duration: '3:00' }, + { id: 'cinematic-1', name: 'Grand Reveal', genre: 'cinematic', duration: '3:30' }, + { id: 'cinematic-2', name: 'Transformation', genre: 'cinematic', duration: '4:00' }, + { id: 'cinematic-3', name: 'Before & After', genre: 'cinematic', duration: '2:55' }, + { id: 'corp-1', name: 'Professional Trust', genre: 'corporate', duration: '2:40' }, + { id: 'corp-2', name: 'Clean & Modern', genre: 'corporate', duration: '3:10' }, + { id: 'corp-3', name: 'Confidence', genre: 'corporate', duration: '2:35' }, +]; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0e05661 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b5d3108 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import path from 'node:path' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + proxy: { + // Dev-only proxy so frontend can hit FastAPI without CORS preflights + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +})