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
commit
535daa3625
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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` 확인 후 선별 병합.
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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[];
|
||||
}
|
||||
|
|
@ -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' },
|
||||
];
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue