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 <noreply@anthropic.com>
main
Haewon Kam 2026-04-17 10:38:42 +09:00
commit 535daa3625
48 changed files with 5755 additions and 0 deletions

12
.env.example Normal file
View File

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

30
.gitignore vendored Normal file
View File

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

86
README.md Normal file
View File

@ -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
```

View File

@ -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` 확인 후 선별 병합.

18
index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
<title>INFINITH — AI 마케팅 분석</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
package.json Normal file
View File

@ -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"
}
}

20
src/App.tsx Normal file
View File

@ -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 (
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/analysis/start" element={<AnalysisStartPage />} />
<Route path="/analysis/:runId/loading" element={<AnalysisLoadingPage />} />
<Route path="/report/:runId" element={<ReportPage />} />
<Route path="/plan/:planId" element={<MarketingPlanPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
)
}

View File

@ -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<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
star: Star,
globe: Globe,
search: Search,
};
const channelLabel: Record<string, string> = {
naverBlog: '네이버 블로그',
naverPlace: '네이버 플레이스',
gangnamUnni: '강남언니',
instagram: 'Instagram',
youtube: 'YouTube',
facebook: 'Facebook',
website: '웹사이트',
tiktok: 'TikTok',
blog: '블로그',
};
const brandColor: Record<string, string> = {
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 (
<SectionWrapper id="channel-overview" title="Channel Health Score" subtitle="채널별 건강도 종합">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
{channels.map((ch, i) => {
const Icon = iconMap[ch.icon?.toLowerCase()] ?? Globe;
const color = getChannelColor(ch.icon);
return (
<motion.div
key={ch.channel}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-4 text-center flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.08 }}
>
<div className="w-10 h-10 rounded-xl bg-slate-50 flex items-center justify-center">
<Icon size={20} style={color ? { color } : undefined} className={color ? '' : 'text-slate-500'} />
</div>
<p className="text-sm font-medium text-[#0A1128]">{channelLabel[ch.channel] || ch.channel}</p>
<ScoreRing score={ch.score} maxScore={ch.maxScore} size={60} color={color} />
<p className="text-xs text-slate-500 line-clamp-2 leading-relaxed">
{ch.headline}
</p>
<SeverityBadge severity={ch.status} />
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -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 (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
{/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
<motion.div
className="flex flex-wrap gap-2 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{data.registryData?.gangnamUnniUrl && (
<a
href={data.registryData.gangnamUnniUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.naverPlaceUrl && (
<a
href={data.registryData.naverPlaceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.googleMapsUrl && (
<a
href={data.registryData.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Google Maps <ExternalLink size={11} />
</a>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{fields.map((field, i) => {
const Icon = field.icon;
return (
<motion.div
key={field.label}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.05 }}
>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Icon size={16} className="text-slate-400" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p>
{field.href ? (
<a
href={field.href}
target={field.href.startsWith('tel:') ? undefined : '_blank'}
rel="noopener noreferrer"
className="text-lg font-semibold text-[#0A1128] mt-1 hover:text-[#6C5CE7] inline-flex items-center gap-1.5"
>
{field.value}
{!field.href.startsWith('tel:') && <ExternalLink size={14} className="text-[#6C5CE7] shrink-0" />}
</a>
) : (
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
)}
</div>
</div>
</motion.div>
);
})}
</div>
{/* Lead Doctor Highlight — only show if doctor name exists */}
{data.leadDoctor.name && (
<motion.div
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-3">
<Award size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
{data.leadDoctor.credentials && (
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
)}
{data.leadDoctor.rating > 0 && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={16}
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
/>
))}
<span className="text-sm font-semibold text-[#0A1128] ml-1">
{data.leadDoctor.rating}
</span>
</div>
{data.leadDoctor.reviewCount > 0 && (
<span className="text-sm text-slate-500">
{formatNumber(data.leadDoctor.reviewCount)}
</span>
)}
</div>
)}
</motion.div>
)}
{/* Certifications */}
{data.certifications.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3"> </p>
<div className="flex flex-wrap gap-2">
{data.certifications.map((cert) => (
<span
key={cert}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{cert}
</span>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -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 (
<motion.div
className={`rounded-2xl border shadow-sm p-6 ${
isKR && isLowFollowers
? 'bg-[#FFF0F0]/30 border-[#F5D5DC]/60'
: 'bg-white border-slate-100'
}`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.15 }}
>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium px-3 py-1 rounded-full ${langColor}`}>
{page.label}
</span>
<div className="w-8 h-8 rounded-lg bg-[#1877F2] flex items-center justify-center">
<Facebook size={16} className="text-white" />
</div>
</div>
{isKR && isLowFollowers && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-[#FFF0F0] text-[#7C3A4B]">
</span>
)}
{page.hasWhatsApp && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C]">
WhatsApp
</span>
)}
</div>
<h3 className="font-bold text-lg text-[#0A1128] mb-1">
{page.url ? (
<a
href={
page.url.startsWith('http')
? page.url
: page.url.startsWith('facebook.com/') || page.url.startsWith('www.facebook.com/')
? `https://${page.url.replace(/^www\./, 'www.')}`
: `https://www.facebook.com/${page.url.replace(/^@/, '')}`
}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#6C5CE7] inline-flex items-center gap-1"
>
{page.pageName}
<ExternalLink size={13} className="text-[#6C5CE7]" />
</a>
) : page.pageName}
</h3>
<p className="text-xs text-slate-500 mb-4">{page.category}</p>
{/* Metrics grid */}
<div className="grid grid-cols-3 gap-2 mb-5">
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${isLowFollowers ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{formatNumber(page.followers)}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className={`text-lg font-bold ${page.reviews === 0 ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{page.reviews}
</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-400 uppercase tracking-wide"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(page.following)}</p>
</div>
</div>
{/* Detail rows */}
<div className="space-y-3 mb-5 text-sm">
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><Eye size={13} /> </span>
<span className="font-medium text-[#0A1128]">{page.recentPostAge}</span>
</div>
{page.postFrequency && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><TrendingUp size={13} /> </span>
<span className="font-medium text-[#0A1128]">{page.postFrequency}</span>
</div>
)}
{page.topContentType && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><ImageIcon size={13} /> </span>
<span className="font-medium text-[#0A1128] text-right text-xs max-w-[180px]">{page.topContentType}</span>
</div>
)}
{page.engagement && (
<div className="flex items-center justify-between py-1 border-b border-slate-100 last:border-0">
<span className="text-slate-500 flex items-center gap-2"><MessageCircle size={13} /> </span>
<span className={`font-medium text-right text-xs max-w-[180px] ${
page.engagement.includes('0~3') ? 'text-[#7C3A4B]' : 'text-[#0A1128]'
}`}>{page.engagement}</span>
</div>
)}
</div>
{/* Logo analysis - the enhanced version */}
<div className={`rounded-xl p-4 mb-4 ${
isLogoMismatch
? 'bg-[#FFF0F0] border border-[#F5D5DC]'
: 'bg-[#F3F0FF] border border-[#D5CDF5]'
}`}>
<div className="flex items-start gap-2 mb-2">
{isLogoMismatch
? <AlertTriangle size={16} className="text-[#7C3A4B] shrink-0 mt-1" />
: <CheckCircle2 size={16} className="text-[#9B8AD4] shrink-0 mt-1" />
}
<p className={`text-sm font-semibold ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
{page.logo}
</p>
</div>
<p className={`text-xs leading-relaxed ml-6 ${isLogoMismatch ? 'text-[#7C3A4B]' : 'text-[#4A3A7C]'}`}>
{page.logoDescription}
</p>
</div>
{/* Domain link */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<div className="flex items-center gap-2 mb-1">
<Link2 size={12} className="text-slate-400" />
<p className="text-xs text-slate-400 uppercase tracking-wide"> </p>
</div>
<p className={`text-sm font-mono ${
page.linkedDomain?.includes('다름') ? 'text-[#7C5C3A]' : 'text-slate-700'
}`}>
{page.linkedDomain || page.link}
</p>
</div>
{/* Bio */}
<div className="rounded-xl bg-slate-50 p-3 mb-4">
<p className="text-xs text-slate-400 uppercase tracking-wide mb-1">Bio</p>
<p className="text-sm text-slate-600 italic">"{page.bio}"</p>
</div>
{/* Link moved to page name header */}
</motion.div>
);
}
/* ─── Brand Inconsistency Map ─── */
function BrandConsistencyMap({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
const [expanded, setExpanded] = useState<number | null>(0);
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-2">Brand Consistency Map</h3>
<p className="text-sm text-slate-500 mb-5"> </p>
<div className="space-y-3">
{inconsistencies.map((item, i) => (
<div
key={item.field}
className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden"
>
{/* Header - clickable */}
<button
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-slate-50/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-900 flex items-center justify-center text-white text-xs font-bold">
{item.values.filter(v => !v.isCorrect).length}
</div>
<div>
<p className="font-semibold text-[#0A1128]">{item.field}</p>
<p className="text-xs text-slate-500">
{item.values.filter(v => !v.isCorrect).length}
</p>
</div>
</div>
<ArrowRight
size={16}
className={`text-slate-400 transition-transform ${expanded === i ? 'rotate-90' : ''}`}
/>
</button>
{/* Expanded content */}
{expanded === i && (
<div className="px-5 pb-5 border-t border-slate-100">
{/* Channel values */}
<div className="grid gap-2 mt-4 mb-4">
{item.values.map((v) => (
<div
key={v.channel}
className={`flex items-center justify-between py-3 px-3 rounded-lg text-sm ${
v.isCorrect ? 'bg-[#F3F0FF]/60' : 'bg-[#FFF0F0]/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">{v.channel}</span>
<span className={`flex-1 text-right ${v.isCorrect ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect
? <CheckCircle2 size={15} className="text-[#9B8AD4]" />
: <XCircle size={15} className="text-[#D4889A]" />
}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-[#FFF6ED] border border-[#F5E0C5] p-4 mb-3">
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
Impact
</p>
<p className="text-sm text-[#7C5C3A]">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4">
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
Recommendation
</p>
<p className="text-sm text-[#4A3A7C]">{item.recommendation}</p>
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Diagnosis Section ─── */
function DiagnosisSection({ items }: { items: DiagnosisItem[] }) {
return (
<motion.div
className="mt-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-2"> </h3>
<p className="text-sm text-slate-500 mb-4">Facebook </p>
<div className="rounded-2xl border border-slate-100 bg-white shadow-sm overflow-hidden">
{items.map((item, i) => (
<div
key={i}
className={`p-5 ${
i < items.length - 1 ? 'border-b border-slate-100' : ''
}`}
>
<div className="flex items-start gap-4">
<div className="min-w-[120px] md:min-w-[160px]">
<p className="font-semibold text-sm text-[#0A1128]">{item.category}</p>
</div>
<p className="flex-1 text-sm text-slate-600">{item.detail}</p>
<div className="shrink-0">
<SeverityBadge severity={item.severity} />
</div>
</div>
{item.evidenceIds && item.evidenceIds.length > 0 && (
<EvidenceGallery evidenceIds={item.evidenceIds} compact />
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── Consolidation Recommendation ─── */
function ConsolidationCard({ text }: { text: string }) {
return (
<motion.div
className="mt-8 rounded-2xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] p-6 md:p-8 text-white"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Globe size={20} className="text-white" />
</div>
<div>
<h4 className="font-serif font-bold text-2xl mb-2"> </h4>
<p className="text-sm text-purple-200 leading-relaxed">{text}</p>
</div>
</div>
</motion.div>
);
}
/* ─── Main Component ─── */
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
const hasData = data.pages.length > 0 || data.diagnosis.length > 0;
if (!hasData) return null;
return (
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
{/* Page cards side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.pages.map((page, i) => (
<PageCard key={page.pageName} page={page} index={i} />
))}
</div>
{/* Brand Consistency Map - the new enhanced section */}
{data.brandInconsistencies && data.brandInconsistencies.length > 0 && (
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} />
)}
{/* Diagnosis table */}
{data.diagnosis && data.diagnosis.length > 0 && (
<DiagnosisSection items={data.diagnosis} />
)}
{/* Consolidation recommendation */}
{data.consolidationRecommendation && (
<ConsolidationCard text={data.consolidationRecommendation} />
)}
</SectionWrapper>
);
}

View File

@ -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 (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Language badge + handle */}
<div className="flex items-center gap-2 mb-4">
<span className={`text-xs font-medium px-3 py-1 rounded-full ${langColor}`}>
{account.label}
</span>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Instagram size={16} className="text-white" />
</div>
</div>
<h3 className="font-bold text-lg text-[#0A1128] mb-1">
{account.handle ? (
<a
href={`https://instagram.com/${account.handle.replace(/^@/, '')}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#6C5CE7] inline-flex items-center gap-1"
>
{account.handle}
<ExternalLink size={13} className="text-[#6C5CE7]" />
</a>
) : account.handle}
</h3>
<p className="text-xs text-slate-500 mb-4">{account.category}</p>
{/* Compact metrics */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.posts)}</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.followers)}</p>
</div>
<div className="rounded-xl bg-slate-50 p-3 text-center">
<p className="text-xs text-slate-500"></p>
<p className="text-lg font-bold text-[#0A1128]">{formatNumber(account.following)}</p>
</div>
</div>
{/* Format & reels */}
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500"> </span>
<span className="font-medium text-[#0A1128]">{account.contentFormat}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500"> </span>
<span className={`font-medium ${account.reelsCount === 0 ? 'text-[#7C3A4B]' : 'text-[#0A1128]'}`}>
{account.reelsCount === 0 ? (
<span className="inline-flex items-center gap-1">
<AlertCircle size={14} className="text-[#D4889A]" />
0 ()
</span>
) : (
account.reelsCount
)}
</span>
</div>
</div>
{/* Highlights */}
{account.highlights.length > 0 && (
<div className="mb-4">
<p className="text-xs text-slate-500 mb-2"></p>
<div className="flex flex-wrap gap-2">
{account.highlights.map((h) => (
<span
key={h}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{h}
</span>
))}
</div>
</div>
)}
{/* Bio */}
{account.bio && (
<div className="rounded-xl bg-slate-50 p-3">
<p className="text-xs text-slate-400 mb-1">Bio</p>
<p className="text-sm text-slate-600 whitespace-pre-line">{account.bio}</p>
</div>
)}
</motion.div>
);
}
export default function InstagramAudit({ data }: InstagramAuditProps) {
const hasAccounts = data.accounts.length > 0 && data.accounts.some(a => a.handle || a.followers > 0);
return (
<SectionWrapper id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
{!hasAccounts && data.diagnosis.length === 0 && (
<EmptyState
message="Instagram 계정 데이터 수집 중"
subtext="채널 데이터 보강이 완료되면 계정 정보와 분석 결과가 표시됩니다."
/>
)}
{/* Account cards */}
{hasAccounts && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{data.accounts.map((account, i) => (
<AccountCard key={account.handle || i} account={account} index={i} />
))}
</div>
)}
{/* Diagnosis */}
{data.diagnosis.length > 0 && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> </p>
{data.diagnosis.map((item, i) => (
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
))}
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -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 (
<SectionWrapper id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표">
{/* KPI Table */}
<motion.div
className="rounded-2xl overflow-hidden border border-slate-100 mb-10"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="grid grid-cols-4 bg-[#0A1128] text-white">
<div className="px-6 py-4 text-sm font-semibold">Metric</div>
<div className="px-6 py-4 text-sm font-semibold">Current</div>
<div className="px-6 py-4 text-sm font-semibold">3-Month Target</div>
<div className="px-6 py-4 text-sm font-semibold">12-Month Target</div>
</div>
{/* Data rows */}
{metrics.map((metric, i) => (
<motion.div
key={metric.metric}
className={`grid grid-cols-4 items-center ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50/60'} border-b border-slate-100 last:border-b-0`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.03 }}
>
<div className="px-6 py-4 text-sm font-medium text-[#0A1128]">{metric.metric}</div>
<div
className={`px-6 py-4 text-sm font-semibold ${
isNegativeValue(metric.current) ? 'text-[#7C3A4B]' : 'text-[#0A1128]'
}`}
>
{formatKpiValue(metric.current)}
</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{formatKpiValue(metric.target3Month)}</div>
<div className="px-6 py-4 text-sm font-medium text-[#4A3A7C]">{formatKpiValue(metric.target12Month)}</div>
</motion.div>
))}
</motion.div>
{/* CTA Card */}
<motion.div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] p-8 md:p-12 text-center relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-[#021341]/10 flex items-center justify-center mx-auto mb-6">
<TrendingUp size={28} className="text-[#021341]" />
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-[#021341] mb-3">
Start Your Transformation
</h3>
<p className="text-[#021341]/60 mb-8 max-w-xl mx-auto">
INFINITH . 90 .
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
onClick={() => navigate(`/plan/${id || 'live'}#branding-guide`)}
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
>
<ArrowUpRight size={18} />
</button>
<button
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
disabled={isExporting}
className="inline-flex items-center gap-2 bg-white border border-slate-200 text-[#021341] font-semibold px-8 py-4 rounded-full hover:bg-slate-50 shadow-sm hover:shadow-md transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
<>
<Download size={18} />
</>
)}
</button>
</div>
</div>
</motion.div>
</SectionWrapper>
);
}

View File

@ -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 (
<SectionWrapper id="other-channels" title="Other Channels & Website" subtitle="기타 채널 및 웹사이트 기술 진단">
{/* Other Channels */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<div className="px-6 py-4 border-b border-slate-100">
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<div className="divide-y divide-slate-100">
{channels.map((ch, i) => {
const cfg = statusConfig[ch.status];
const StatusIcon = cfg.icon;
return (
<motion.div
key={ch.name}
className="flex items-center gap-4 px-6 py-4"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<StatusIcon size={20} className={cfg.color} />
<div className="flex-1 min-w-0">
{ch.url ? (
<a
href={ch.url.startsWith('http') ? ch.url : `https://${ch.url}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold text-[#0A1128] hover:text-[#6C5CE7] inline-flex items-center gap-1"
>
{ch.name}
<ExternalLink size={12} className="text-[#6C5CE7] shrink-0" />
</a>
) : (
<p className="text-sm font-semibold text-[#0A1128]">{ch.name}</p>
)}
<p className="text-sm text-slate-500">{ch.details}</p>
</div>
<span className={`text-xs font-medium ${cfg.color}`}>{cfg.label}</span>
</motion.div>
);
})}
</div>
</motion.div>
{/* Website Tech Audit */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex items-center gap-2 mb-6">
<Globe size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
{/* Domain info */}
<div className="rounded-xl bg-slate-50 p-4 mb-6">
<p className="text-sm font-semibold text-[#0A1128] mb-2"> : {website.primaryDomain}</p>
{website.additionalDomains.length > 0 && (
<div className="mt-2 space-y-1">
{website.additionalDomains.map((d) => (
<p key={d.domain} className="text-sm text-slate-600">
{d.domain} <span className="text-slate-400">{d.purpose}</span>
</p>
))}
</div>
)}
<p className="text-sm text-slate-500 mt-2">
CTA: <span className="font-medium text-[#0A1128]">{website.mainCTA}</span>
</p>
</div>
{/* Tracking Pixels */}
<div className="mb-6">
<p className="text-sm font-semibold text-slate-700 mb-3"> </p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{website.trackingPixels.map((pixel) => (
<div
key={pixel.name}
className={`flex items-center gap-2 rounded-xl p-3 ${
pixel.installed
? 'bg-[#F3F0FF] border border-[#D5CDF5]'
: 'bg-[#FFF0F0] border border-[#F5D5DC]'
}`}
>
{pixel.installed ? (
<CheckCircle2 size={16} className="text-[#9B8AD4] shrink-0" />
) : (
<XCircle size={16} className="text-[#D4889A] shrink-0" />
)}
<div className="min-w-0">
<p className={`text-sm font-medium ${pixel.installed ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>
{pixel.name}
</p>
{pixel.details && (
<p className="text-xs text-slate-500 truncate">{pixel.details}</p>
)}
</div>
</div>
))}
</div>
</div>
{/* SNS Links Status */}
{!website.snsLinksOnSite && (
<motion.div
className="rounded-xl bg-[#FFF0F0] border border-[#F5D5DC] p-4 flex items-start gap-3"
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<AlertCircle size={20} className="text-[#7C3A4B] shrink-0 mt-1" />
<div>
<p className="text-sm font-bold text-[#7C3A4B]"> SNS </p>
<p className="text-sm text-[#7C3A4B] mt-1">
. SNS .
</p>
</div>
</motion.div>
)}
{website.snsLinksOnSite && (
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4 flex items-center gap-3">
<CheckCircle2 size={20} className="text-[#9B8AD4]" />
<p className="text-sm font-medium text-[#4A3A7C]"> SNS </p>
</div>
)}
</motion.div>
</SectionWrapper>
);
}

View File

@ -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 (
<SectionWrapper id="problem-diagnosis" title="Critical Issues" subtitle="핵심 문제 진단" dark>
{/* Core 3 problem cards — large, prominent */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{clusters.map((cluster, i) => {
const Icon = cluster.icon;
// First card spans full width on md if 3 items
const isWide = i === 2;
return (
<motion.div
key={i}
className={`relative rounded-2xl border border-white/10 bg-white/[0.06] backdrop-blur-md p-7 overflow-hidden ${
isWide ? 'md:col-span-2' : ''
}`}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.12 }}
>
{/* Glow accent */}
<div className="absolute -top-6 -right-6 w-24 h-24 bg-[#C084CF]/20 rounded-full blur-2xl pointer-events-none" />
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center">
<Icon size={20} className="text-[#E8B4C0]" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-bold text-white mb-2 leading-snug">{cluster.title}</h3>
<p className="text-sm text-purple-200/80 leading-relaxed">{cluster.detail}</p>
</div>
</div>
{/* Severity indicator dot */}
<div className="absolute top-4 right-4">
<span className="block w-3 h-3 rounded-full bg-[#C084CF] shadow-[0_0_8px_rgba(192,132,207,0.6)]" />
</div>
</motion.div>
);
})}
</div>
{/* Detailed diagnosis items — compact list below */}
{diagnosis.length > 3 && (
<motion.div
className="rounded-2xl border border-white/10 bg-white/[0.04] backdrop-blur-sm overflow-hidden"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="px-6 py-3 border-b border-white/10">
<h4 className="text-xs uppercase tracking-wider text-purple-300/60 font-semibold">
({diagnosis.length})
</h4>
</div>
<div className="divide-y divide-white/5">
{diagnosis.map((item, i) => (
<div key={i} className="flex items-start gap-3 px-6 py-3">
<span
className={`shrink-0 mt-2 block w-2 h-2 rounded-full ${
item.severity === 'critical'
? 'bg-[#E8B4C0]'
: item.severity === 'warning'
? 'bg-[#8B9CF7]'
: item.severity === 'good'
? 'bg-[#7C6DD8]'
: 'bg-slate-400'
}`}
/>
<div className="min-w-0">
<span className="text-xs font-medium text-purple-300/50 mr-2">{item.category}</span>
<span className="text-sm text-purple-100/80">{item.detail}</span>
</div>
</div>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -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 (
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 px-6">
{/* Animated blobs */}
<motion.div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
/>
<div className="relative max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
{/* Left: Text content */}
<motion.div
className="flex-1 text-center md:text-left"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<motion.p
className="font-serif text-3xl md:text-4xl font-normal text-[#6C5CE7] mb-4 tracking-wide"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
>
Marketing Intelligence Report
</motion.p>
{logoImage && (
<motion.div
className="mb-4"
initial={{ opacity: 0, scale: 0.9 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<img
src={logoImage}
alt={clinicName}
className="h-16 md:h-20 w-auto object-contain md:mx-0 mx-auto"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</motion.div>
)}
<motion.h1
className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
{clinicName}
</motion.h1>
<motion.p
className="text-lg text-slate-600 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
{clinicNameEn}
</motion.p>
<motion.div
className="flex flex-wrap gap-3 justify-center md:justify-start"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Calendar size={14} className="text-slate-400" />
{formatDate(date)}
</span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Globe size={14} className="text-slate-400" />
{targetUrl}
</span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<MapPin size={14} className="text-slate-400" />
{location}
</span>
</motion.div>
</motion.div>
{/* Right: Score ring */}
<motion.div
className="shrink-0"
initial={{ opacity: 0, scale: 0.8 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<div className="bg-white/60 backdrop-blur-sm border border-white/40 rounded-3xl p-8 shadow-lg">
<p className="text-xs text-slate-500 uppercase tracking-wide text-center mb-4">
Overall Score
</p>
<ScoreRing score={overallScore} size={160} label="종합 점수" />
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -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<HTMLDivElement>(null);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(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 (
<nav data-report-nav className="sticky top-20 z-40 bg-white/95 border-b border-slate-100">
<div
ref={navRef}
className="max-w-7xl mx-auto flex overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{sections.map(({ id, label }) => (
<button
key={id}
ref={(el) => {
if (el) tabRefs.current.set(id, el);
}}
onClick={() => handleClick(id)}
className={`
shrink-0 px-4 py-3 text-sm font-medium transition-colors whitespace-nowrap
border-b-2
${activeId === id
? 'border-[#6C5CE7] text-[#0A1128]'
: 'border-transparent text-slate-500 hover:text-slate-700'
}
`}
>
{label}
</button>
))}
</div>
</nav>
);
}

View File

@ -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<number, { badge: string; accent: string }> = {
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 (
<SectionWrapper id="roadmap" title="90-Day Roadmap" subtitle="실행 로드맵">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{months.map((month, i) => {
const meta = monthMeta[month.month] || monthMeta[1];
return (
<motion.div
key={month.month}
className={`rounded-2xl bg-white border ${meta.accent} shadow-sm overflow-hidden`}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.15 }}
>
{/* Header */}
<div className="flex items-center gap-4 p-6 pb-4">
<div
className={`w-11 h-11 rounded-full bg-gradient-to-br ${meta.badge} text-white flex items-center justify-center font-bold text-sm shrink-0`}
>
{month.month}
</div>
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128] leading-tight">
{month.title}
</h3>
<p className="text-sm text-slate-500">{month.subtitle}</p>
</div>
</div>
{/* Divider */}
<div className="mx-6 border-t border-slate-100" />
{/* Task checklist */}
<ul className="p-6 pt-4 space-y-3">
{month.tasks.map((task, j) => (
<motion.li
key={j}
className="flex items-start gap-3"
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.15 + j * 0.05 }}
>
{task.completed ? (
<CheckCircle2 size={18} className="text-[#6C5CE7] shrink-0 mt-0.5" />
) : (
<Circle size={18} className="text-slate-300 shrink-0 mt-0.5" />
)}
<span
className={`text-sm leading-relaxed ${
task.completed ? 'text-slate-400 line-through' : 'text-slate-700'
}`}
>
{task.task}
</span>
</motion.li>
))}
</ul>
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -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<string, ComponentType<{ size?: number; className?: string }>> = {
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 (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#6C5CE7]/10 to-[#4F1DA1]/10 flex items-center justify-center">
<Icon size={20} className="text-[#6C5CE7]" />
</div>
<h4 className="font-bold text-[#0A1128]">{strategy.platform}</h4>
</div>
{/* Current to Target */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className="rounded-lg bg-[#FFF0F0] px-3 py-2 text-[#7C3A4B] font-medium">
{strategy.currentMetric}
</span>
<ArrowUpRight size={16} className="text-slate-400" />
<span className="rounded-lg bg-[#F3F0FF] px-3 py-2 text-[#4A3A7C] font-medium">
{strategy.targetMetric}
</span>
</div>
{/* Strategy bullets */}
<ul className="space-y-2">
{strategy.strategies.map((s, i) => (
<li key={i} className="flex items-start gap-2">
<span className="shrink-0 w-2 h-2 rounded-full bg-[#6C5CE7] mt-2" />
<div>
<p className="text-sm font-medium text-[#0A1128]">{s.strategy}</p>
<p className="text-xs text-slate-500">{s.detail}</p>
</div>
</li>
))}
</ul>
</motion.div>
);
}
const priorityColor: Record<string, string> = {
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<TabKey>('brand');
return (
<SectionWrapper id="transformation" title="Transformation Proposal" subtitle="As-Is → To-Be 전환 제안">
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
activeTab === tab.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-lg'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Brand Identity */}
{activeTab === 'brand' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.brandIdentity.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* Content Strategy */}
{activeTab === 'content' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.contentStrategy.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* Platform Strategies */}
{activeTab === 'platform' && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.platformStrategies.map((strategy, i) => (
<PlatformStrategyCard key={strategy.platform} strategy={strategy} index={i} />
))}
</div>
)}
{/* Website Improvements */}
{activeTab === 'website' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4"> </h3>
{data.websiteImprovements.map((item, i) => (
<ComparisonRow key={i} area={item.area} asIs={item.asIs} toBe={item.toBe} />
))}
</motion.div>
)}
{/* New Channel Proposals */}
{activeTab === 'newChannel' && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<table className="w-full text-left">
<thead>
<tr className="bg-slate-50 border-b border-slate-100">
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
<th className="px-6 py-3 text-xs font-semibold text-slate-500 uppercase tracking-wide"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{data.newChannelProposals.map((ch, i) => (
<motion.tr
key={ch.channel}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<td className="px-6 py-4 text-sm font-medium text-[#0A1128]">{ch.channel}</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${
priorityColor[ch.priority.toLowerCase()] ?? 'bg-slate-50 text-slate-700 border-slate-200'
}`}
>
{ch.priority}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-600">{ch.rationale}</td>
</motion.tr>
))}
</tbody>
</table>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -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 (
<SectionWrapper id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
{!hasData && (
<EmptyState
message="YouTube 채널 데이터 수집 중"
subtext="채널 데이터 보강이 완료되면 구독자, 영상, 조회수 정보가 표시됩니다."
/>
)}
{hasData && <>
{/* Metrics row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<MetricCard
label="구독자"
value={formatNumber(data.subscribers)}
icon={<Users size={20} />}
subtext={data.subscriberRank}
/>
<MetricCard
label="총 영상 수"
value={formatNumber(data.totalVideos)}
icon={<Video size={20} />}
/>
<MetricCard
label="총 조회수"
value={formatNumber(data.totalViews)}
icon={<Eye size={20} />}
/>
<MetricCard
label="주간 성장"
value={`+${formatNumber(data.weeklyViewGrowth.absolute)}`}
icon={<TrendingUp size={20} />}
subtext={`${data.weeklyViewGrowth.percentage > 0 ? '+' : ''}${data.weeklyViewGrowth.percentage}%`}
trend={data.weeklyViewGrowth.percentage > 0 ? 'up' : data.weeklyViewGrowth.percentage < 0 ? 'down' : 'neutral'}
/>
</div>
{/* Channel info card */}
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#FFF0F0] flex items-center justify-center">
<Youtube size={20} className="text-[#D4889A]" />
</div>
<div>
<p className="font-bold text-[#0A1128]">{data.channelName}</p>
{data.handle ? (
<a
href={`https://www.youtube.com/${data.handle.startsWith('@') || data.handle.startsWith('UC') ? data.handle : `@${data.handle}`}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#6C5CE7] hover:underline inline-flex items-center gap-1"
>
{data.handle}
<ExternalLink size={11} />
</a>
) : (
<p className="text-sm text-slate-500">{data.handle}</p>
)}
</div>
</div>
<p className="text-sm text-slate-600 mb-4">{data.channelDescription}</p>
<div className="flex flex-wrap gap-3 text-sm text-slate-500">
<span>: {data.channelCreatedDate}</span>
<span> : {data.avgVideoLength}</span>
<span> : {data.uploadFrequency}</span>
</div>
{/* Linked URLs */}
{data.linkedUrls.length > 0 && (
<div className="mt-4 flex flex-wrap gap-2">
{data.linkedUrls.map((link) => (
<a
key={link.url}
href={link.url.startsWith('http') ? link.url : `https://${link.url}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-[#6C5CE7] hover:underline"
>
<ExternalLink size={12} />
{link.label}
</a>
))}
</div>
)}
</motion.div>
{/* Playlists */}
{data.playlists.length > 0 && (
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.25 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3"></p>
<div className="flex flex-wrap gap-2">
{data.playlists.map((pl) => (
<span
key={pl}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{pl}
</span>
))}
</div>
</motion.div>
)}
{/* Top Videos */}
{data.topVideos.length > 0 && (
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<p className="text-sm font-semibold text-slate-700 mb-4"> TOP {data.topVideos.length}</p>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-slate-200">
{data.topVideos.map((video, i) => (
<motion.div
key={i}
className="rounded-xl bg-white border border-slate-100 shadow-sm p-4 min-w-[250px] shrink-0"
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.05 }}
>
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs font-medium px-2 py-1 rounded-full ${
video.type === 'Short'
? 'bg-purple-50 text-purple-700'
: 'bg-[#EFF0FF] text-[#3A3F7C]'
}`}
>
{video.type}
</span>
<span className="text-xs text-slate-400">{video.uploadedAgo}</span>
</div>
<p className="text-sm font-medium text-[#0A1128] line-clamp-2 mb-2">
{video.title}
</p>
<div className="flex items-center gap-1 text-sm text-slate-600">
<Eye size={14} className="text-slate-400" />
<span className="font-semibold">{formatNumber(video.views)}</span>
<span className="text-slate-400">views</span>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
{/* Diagnosis — metric-based findings */}
{(data.diagnosis.length > 0 || data.subscribers > 0) && (
<motion.div
className="rounded-2xl bg-white border border-slate-100 shadow-sm overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.35 }}
>
<div className="px-6 py-4 border-b border-slate-100">
<p className="text-sm font-semibold text-slate-700"> </p>
</div>
{/* Quick metric diagnosis rows */}
{data.subscribers > 0 && data.totalViews > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
~{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'}% )
</span>
</div>
)}
{data.topVideos.length > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
1,000~4,000
</span>
</div>
)}
{data.topVideos.filter(v => v.type === 'Short').length === 0 && data.totalVideos > 0 && (
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap">Shorts </span>
<span className="text-sm text-slate-500"> 500~1000 ( )</span>
</div>
)}
<div className="px-6 py-3 border-b border-slate-50 flex items-baseline gap-2">
<span className="text-sm font-semibold text-[#0A1128] whitespace-nowrap"> </span>
<span className="text-sm text-slate-500">
{data.uploadFrequency || '주 1회'} 3
</span>
</div>
{/* Detailed diagnosis items */}
{data.diagnosis.length > 0 && (
<div className="px-6 py-4">
{data.diagnosis.map((item, i) => (
<DiagnosisRow key={i} category={item.category} detail={item.detail} severity={item.severity} evidenceIds={item.evidenceIds} />
))}
</div>
)}
</motion.div>
)}
</>}
</SectionWrapper>
);
}

View File

@ -0,0 +1,20 @@
interface ComparisonRowProps {
key?: string | number;
area: string;
asIs: string;
toBe: string;
}
export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) {
return (
<div className="grid grid-cols-[120px_1fr_1fr] gap-3 items-start py-4 border-b border-slate-100 last:border-0">
<span className="font-medium text-sm text-slate-700 pt-3">{area}</span>
<div className="bg-[#FFF0F0]/50 rounded-lg p-3 text-sm text-slate-600">
{asIs}
</div>
<div className="bg-[#F3F0FF]/50 rounded-lg p-3 text-sm text-slate-700 font-medium">
{toBe}
</div>
</div>
);
}

View File

@ -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 (
<div className="py-4 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-4">
<span className="font-bold text-sm text-[#0A1128] shrink-0 w-28">
{category}
</span>
<p className="flex-1 text-sm text-slate-600">{detail}</p>
<div className="shrink-0">
<SeverityBadge severity={severity} />
</div>
</div>
<EvidenceGallery evidenceIds={evidenceIds} compact />
</div>
);
}

View File

@ -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<EmptyStatus, {
icon: typeof Search;
iconColor: string;
bgColor: string;
defaultMessage: string;
defaultSubtext: string;
}> = {
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<EmptyStatus>(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 (
<motion.div
className="flex flex-col items-center justify-center py-12 text-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<div className={`w-12 h-12 rounded-2xl ${config.bgColor} flex items-center justify-center mb-4`}>
{currentStatus === 'loading' ? (
<div className="w-5 h-5 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
) : (
<Icon size={20} className={config.iconColor} />
)}
</div>
<p className="text-sm font-medium text-slate-500">{message || config.defaultMessage}</p>
<p className="text-xs text-slate-400 mt-1 max-w-xs">{subtext || config.defaultSubtext}</p>
{onRetry && (currentStatus === 'error' || currentStatus === 'timeout') && (
<button
onClick={onRetry}
className="mt-4 flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors"
>
<RefreshCw size={12} />
</button>
)}
</motion.div>
);
}

View File

@ -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 (
<div className="mt-3">
<EvidenceScreenshot evidence={items[0]} compact={compact} />
</div>
);
}
return (
<div className="mt-3 flex gap-3 overflow-x-auto pb-2" style={{ scrollbarWidth: 'thin' }}>
{items.map((item) => (
<div key={item.id} className="shrink-0 w-[200px]">
<EvidenceScreenshot evidence={item} compact />
</div>
))}
</div>
);
}

View File

@ -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 (
<AnimatePresence>
{evidence && (
<motion.div
data-no-print
className="fixed inset-0 z-50 flex items-center justify-center p-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Content */}
<motion.div
className="relative max-w-5xl w-full max-h-[90vh] flex flex-col"
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute -top-3 -right-3 z-10 w-8 h-8 rounded-full bg-white shadow-md flex items-center justify-center hover:bg-slate-50 transition-colors"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2 2L12 12M12 2L2 12" stroke="#0A1128" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
{/* Image container with annotations */}
<div className="relative rounded-2xl overflow-hidden bg-white shadow-2xl">
<img
src={evidence.url}
alt={evidence.caption}
className="w-full h-auto max-h-[70vh] object-contain"
/>
{/* Annotation overlays */}
{evidence.annotations?.map((ann, i) => (
<div
key={i}
className="absolute border-2 border-[#C084CF] rounded-lg pointer-events-none"
style={{
left: `${ann.x}%`,
top: `${ann.y}%`,
width: ann.width ? `${ann.width}%` : 'auto',
height: ann.height ? `${ann.height}%` : 'auto',
}}
>
{ann.label && (
<span className="absolute -top-6 left-0 text-xs bg-[#C084CF] text-white px-2 py-1 rounded font-medium whitespace-nowrap">
{ann.label}
</span>
)}
</div>
))}
</div>
{/* Caption */}
<div className="mt-3 bg-white rounded-xl px-4 py-3 shadow-sm">
<p className="text-sm text-[#0A1128] font-medium">{evidence.caption}</p>
{evidence.sourceUrl && (
<p className="text-xs text-slate-400 mt-1">{evidence.sourceUrl}</p>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -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 (
<>
<button
type="button"
onClick={() => setLightboxOpen(true)}
className={`group relative rounded-xl overflow-hidden border border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-all cursor-pointer ${
compact ? 'w-full' : 'w-full max-w-[400px]'
}`}
>
<div className="relative">
<img
src={evidence.url}
alt={evidence.caption}
className={`w-full object-cover ${compact ? 'h-24' : 'h-48'}`}
onError={() => setLoaded(false)}
/>
{/* Hover overlay */}
<div className="absolute inset-0 bg-[#0A1128]/0 group-hover:bg-[#0A1128]/10 transition-colors flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M15 3H21V9M21 3L13 11M10 5H5C3.9 5 3 5.9 3 7V19C3 20.1 3.9 21 5 21H17C18.1 21 19 20.1 19 19V14" stroke="#6C5CE7" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
{/* Channel badge */}
<span className="absolute top-2 left-2 rounded-full bg-white/90 backdrop-blur-sm px-2 py-1 text-xs font-medium text-[#4A3A7C] shadow-sm">
{evidence.channel}
</span>
{/* Annotation indicators */}
{evidence.annotations?.map((ann, i) => (
<div
key={i}
className="absolute border-2 border-[#C084CF]/60 rounded-lg pointer-events-none"
style={{
left: `${ann.x}%`,
top: `${ann.y}%`,
width: ann.width ? `${ann.width}%` : 'auto',
height: ann.height ? `${ann.height}%` : 'auto',
}}
/>
))}
</div>
{/* Caption */}
{!compact && (
<div className="px-3 py-2">
<p className="text-xs text-slate-600 leading-relaxed line-clamp-2">
{evidence.caption}
</p>
</div>
)}
</button>
<EvidenceLightbox
evidence={lightboxOpen ? evidence : null}
onClose={() => setLightboxOpen(false)}
/>
</>
);
}

View File

@ -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 (
<div className="rounded-2xl border border-slate-100 shadow-sm bg-white p-5 relative">
{icon && (
<div className="absolute top-4 right-4 text-slate-300">
{icon}
</div>
)}
<p className="text-sm text-slate-500 mb-1">{label}</p>
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-[#0A1128]">{value}</span>
{trend && (
<span className={`${trendConfig[trend].color} mb-1`}>
{(() => {
const TrendIcon = trendConfig[trend].icon;
return <TrendIcon size={18} />;
})()}
</span>
)}
</div>
{subtext && (
<p className="text-xs text-slate-400 mt-1">{subtext}</p>
)}
</div>
);
}

View File

@ -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 (
<div className="flex flex-col items-center gap-2">
<div className="relative" style={{ width: size, height: size }}>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="-rotate-90"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#f1f5f9"
strokeWidth={strokeWidth}
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={resolvedColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{ duration: 1, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-[#0A1128]">{score}</span>
</div>
</div>
{label && (
<span className="text-sm text-slate-500 text-center">{label}</span>
)}
</div>
);
}

View File

@ -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<Props, State> {
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 ?? (
<div className="px-6 py-4 bg-red-50 border border-red-200 rounded-xl my-4 mx-4">
<p className="text-xs font-mono text-red-600"> ( )</p>
</div>
);
}
return this.props.children;
}
}

View File

@ -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 (
<section
id={id}
className={`
${dark
? 'bg-[#0A1128] text-white relative overflow-hidden'
: 'bg-white'
}
py-16 md:py-20 px-6
${className}
`}
>
{dark && (
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.15),transparent_60%)]" />
)}
<div className="relative max-w-7xl mx-auto">
<div className="mb-10">
<h2
className={`
font-serif font-bold text-3xl md:text-4xl mb-3
${dark
? 'bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent'
: 'text-gradient'
}
`}
>
{title}
</h2>
{subtitle && (
<p
className={`text-lg ${dark ? 'text-purple-200' : 'text-slate-600'}`}
>
{subtitle}
</p>
)}
</div>
{children}
</div>
</section>
);
}

View File

@ -0,0 +1,41 @@
type SeverityLevel = 'critical' | 'warning' | 'good' | 'excellent' | 'unknown';
interface SeverityBadgeProps {
severity: SeverityLevel;
label?: string;
}
const config: Record<SeverityLevel, { className: string; defaultLabel: string }> = {
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 (
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${className}`}
>
{label ?? defaultLabel}
</span>
);
}

76
src/hooks/useAnalysis.ts Normal file
View File

@ -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<string, string>
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<StatusResponse | null>(null)
const [error, setError] = useState<Error | null>(null)
const pollerRef = useRef<number | null>(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<StatusResponse>(`/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 }
}

50
src/hooks/useReport.ts Normal file
View File

@ -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<MarketingReport | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(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 }
}

32
src/index.css Normal file
View File

@ -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;
}
}

36
src/lib/apiClient.ts Normal file
View File

@ -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)
},
)

417
src/lib/transformPlan.ts Normal file
View File

@ -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<string, unknown>;
scrape_data?: Record<string, unknown>;
analysis_data?: Record<string, unknown>;
created_at: string;
}
const CHANNEL_NAME_MAP: Record<string, string> = {
naverBlog: '네이버 블로그',
naverPlace: '네이버 플레이스',
gangnamUnni: '강남언니',
instagram: 'Instagram',
youtube: 'YouTube',
facebook: 'Facebook',
website: '웹사이트',
tiktok: 'TikTok',
};
const CHANNEL_ICON_MAP: Record<string, string> = {
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<string, string> = {
youtube: '교육적 · 권위 (Long) / 캐주얼 · 후킹 (Shorts)',
instagram: '트렌디 · 공감 (Reel) / 감성적 · 프리미엄 (Feed)',
naverBlog: '정보성 · SEO 최적화',
gangnamUnni: '전문 · 응대 · 신뢰',
facebook: '타겟팅 · CTA 중심',
tiktok: '밈 · 교육 · MZ세대',
naverPlace: '정보성 · 지역 SEO',
website: '브랜드 · 프리미엄',
};
function buildChannelStrategies(
channelAnalysis: Record<string, Record<string, unknown>> | undefined,
recommendations: Record<string, unknown>[] | 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<string, unknown>[] | 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<string, unknown>,
) {
// 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | undefined;
const channelAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
const recommendations = report.recommendations as Record<string, unknown>[] | undefined;
const services = (clinicInfo?.services as string[]) || [];
const enrichment = report.channelEnrichment as EnrichmentData | undefined;
const scrapeData = row.scrape_data as Record<string, unknown> | undefined;
const branding = scrapeData?.branding as Record<string, unknown> | 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),
};
}

1268
src/lib/transformReport.ts Normal file

File diff suppressed because it is too large Load Diff

13
src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@ -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 (
<div className="min-h-screen flex flex-col items-center justify-center px-6">
<div className="glass-card p-10 max-w-md w-full text-center">
<h1 className="font-serif text-2xl font-bold mb-4"> </h1>
<p className="text-white/70 mb-6">{status?.current_step ?? '초기화 중…'}</p>
<div className="h-2 bg-white/10 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-accent transition-all duration-500"
style={{ width: `${(status?.progress ?? 0) * 100}%` }}
/>
</div>
<p className="text-sm text-white/50">
: <span className="font-mono">{status?.status ?? 'pending'}</span>
</p>
{status?.status === 'error' && (
<p className="text-status-critical mt-6 text-sm">
: {status.channel_errors._pipeline ?? '알 수 없는 오류'}
</p>
)}
</div>
</div>
)
}

View File

@ -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 (
<div className="min-h-screen px-6 py-16 flex justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-2xl glass-card p-10">
<h1 className="font-serif text-3xl font-bold mb-8"> </h1>
<Field label="병원 홈페이지 URL *" required>
<input
required
type="url"
placeholder="https://www.example.com"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
className="input"
/>
</Field>
<h2 className="text-lg font-semibold mt-8 mb-4 text-white/80"> ( 1)</h2>
<Field label="YouTube 핸들">
<input
placeholder="@viewclinic"
value={form.youtube}
onChange={(e) => setForm({ ...form, youtube: e.target.value })}
className="input"
/>
</Field>
<Field label="Instagram 핸들">
<input
placeholder="@clinic_official"
value={form.instagram}
onChange={(e) => setForm({ ...form, instagram: e.target.value })}
className="input"
/>
</Field>
<Field label="Facebook 페이지">
<input
placeholder="clinicofficial"
value={form.facebook}
onChange={(e) => setForm({ ...form, facebook: e.target.value })}
className="input"
/>
</Field>
<Field label="네이버 블로그 URL">
<input
type="url"
placeholder="https://blog.naver.com/clinic"
value={form.naver_blog}
onChange={(e) => setForm({ ...form, naver_blog: e.target.value })}
className="input"
/>
</Field>
<Field label="강남언니 URL">
<input
type="url"
placeholder="https://www.gangnamunni.com/hospital/..."
value={form.gangnam_unni}
onChange={(e) => setForm({ ...form, gangnam_unni: e.target.value })}
className="input"
/>
</Field>
{error && <p className="text-status-critical mt-4 text-sm">{error.message}</p>}
<button
type="submit"
disabled={isSubmitting}
className="w-full mt-8 bg-accent hover:bg-accent-dark disabled:opacity-50 text-white font-semibold py-4 rounded-xl transition"
>
{isSubmitting ? '분석 요청 중…' : '분석 시작'}
</button>
</form>
<style>{`
.input {
width: 100%;
padding: 12px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 12px;
color: white;
outline: none;
}
.input:focus { border-color: var(--color-accent); }
`}</style>
</div>
)
}
function Field({
label,
required,
children,
}: {
label: string
required?: boolean
children: React.ReactNode
}) {
return (
<label className="block mb-4">
<span className="block text-sm text-white/70 mb-2">
{label}
{required && <span className="text-status-critical ml-1">*</span>}
</span>
{children}
</label>
)
}

30
src/pages/LandingPage.tsx Normal file
View File

@ -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 (
<div className="min-h-screen flex flex-col items-center justify-center px-6">
<div className="max-w-3xl text-center">
<h1 className="font-serif text-5xl md:text-6xl font-bold text-white mb-6">
INFINITH
</h1>
<p className="text-lg md:text-xl text-white/70 mb-10">
AI
<br />
· · · , .
</p>
<Link
to="/analysis/start"
className="inline-block bg-accent hover:bg-accent-dark text-white font-semibold px-8 py-4 rounded-full transition"
>
</Link>
</div>
</div>
)
}

View File

@ -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 (
<div className="min-h-screen flex items-center justify-center">
<p className="text-white/50">Plan {planId} (D6)</p>
</div>
)
}

View File

@ -0,0 +1,13 @@
import { Link } from 'react-router-dom'
export default function NotFoundPage() {
return (
<div className="min-h-screen flex flex-col items-center justify-center">
<h1 className="font-serif text-4xl mb-4">404</h1>
<p className="text-white/60 mb-6"> .</p>
<Link to="/" className="text-accent hover:underline">
</Link>
</div>
)
}

31
src/pages/ReportPage.tsx Normal file
View File

@ -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 <div className="min-h-screen flex items-center justify-center"> </div>
if (error) return <div className="min-h-screen flex items-center justify-center text-status-critical">{error.message}</div>
if (!data) return null
return (
<div className="min-h-screen px-6 py-16 max-w-5xl mx-auto">
<h1 className="font-serif text-4xl font-bold mb-8">{data.clinic?.name ?? 'Unknown'} </h1>
<p className="text-white/60 mb-8"> : {data.overall_score}</p>
{/* TODO(D5): <ChannelOverview />, <YouTubeAudit />, <InstagramAudit />, ... */}
<pre className="glass-card p-6 overflow-auto text-xs text-white/70">
{JSON.stringify(data, null, 2)}
</pre>
</div>
)
}

230
src/types/plan.ts Normal file
View File

@ -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;
}

243
src/types/report.ts Normal file
View File

@ -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[];
}

126
src/types/studio.ts Normal file
View File

@ -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' },
];

30
tsconfig.json Normal file
View File

@ -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"]
}

24
vite.config.ts Normal file
View File

@ -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,
},
},
},
})