chore: 프론트엔드 마이그레이션 + 아키텍처 정의

- 백엔드 SDK 이전: FastAPI OpenAPI → orval 자동생성 (React Query 훅 포함)
- HTTP 어댑터: ky 기반 customFetcher, fetch httpClient 시그니처
- 아키텍처: features 모듈 / shared 레이어 컨벤션 정의
- 디자인 시스템: Tailwind v4 CSS-first 토큰 (브랜드 색 / status / shadcn)
- 커스텀 CSS: utilities / animations 를 custom.css 로 분리
- Docker: 개발(Dockerfile.dev + compose) / 프로덕션(nginx) 셋업
- README: 기술 스펙 / 프로젝트 구조 / 디자인 토큰 / 폰트 / SDK 사용법 정리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
main
Mina Choi 2026-05-13 11:37:13 +09:00
commit e66b208318
219 changed files with 36881 additions and 0 deletions

16
.dockerignore Normal file
View File

@ -0,0 +1,16 @@
node_modules
dist
build
.git
.gitignore
.env*
!.env.example
Dockerfile
.dockerignore
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
coverage
.vscode
.idea

1
.env.example Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8001

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.env
.env.local
.env*.local
.DS_Store
*.log
.vite

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN \
if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then corepack enable && yarn install --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm install; fi
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

21
Dockerfile.dev Normal file
View File

@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
# 개발용 — vite dev 서버 (HMR + polling). 프로덕션 빌드는 Dockerfile 사용.
FROM node:20-alpine
WORKDIR /app
# 의존성 설치 (lockfile 우선)
COPY package.json package-lock.json* pnpm-lock.yaml* yarn.lock* ./
RUN \
if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm i --frozen-lockfile; \
elif [ -f yarn.lock ]; then corepack enable && yarn install --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm install; fi
# 소스는 docker-compose에서 volume으로 마운트되므로 여기선 안 복사
EXPOSE 3000
# polling 켜고 dev 서버 시작
ENV CHOKIDAR_USEPOLLING=true
CMD ["npm", "run", "dev"]

183
README.md Normal file
View File

@ -0,0 +1,183 @@
# o2o-infinith-frontend
INFINITH 마케팅 분석 플랫폼의 프론트엔드. React 19 + Vite + Tailwind v4 + TanStack Query.
## 기술 스펙
| 영역 | 라이브러리 / 도구 | 비고 |
| --- | --- | --- |
| 언어 | TypeScript ~5.8 | |
| 빌드 | Vite 6 | `@vitejs/plugin-react` |
| UI 프레임워크 | React 19 | |
| 스타일 | Tailwind CSS v4 | CSS-first 방식 (config 파일 없음) |
| 컴포넌트 | [shadcn/ui](https://ui.shadcn.com/) | Radix + Tailwind 조합, `src/shared/ui/` 에 복사해 사용 |
| 아이콘 | lucide-react, 커스텀 (`src/shared/icons/`) | |
| 애니메이션 | motion (Framer Motion 후속), tw-animate-css | |
| 라우팅 | react-router 7 | |
| 데이터 페칭 | TanStack Query 5 | |
| HTTP 클라이언트 | ky | `customFetcher` 의 베이스 |
| API SDK 생성 | orval 7 | OpenAPI → React Query 훅 |
| 클라이언트 상태 | zustand 5 | |
| PDF 출력 | jspdf, html2canvas-pro | |
| 폰트 | Pretendard Variable (self-host), Playfair Display | |
## 시작하기
### 로컬 (Node)
```bash
npm install
npm run dev # localhost:3000
npm run build # 프로덕션 빌드
npm run lint # tsc --noEmit
npm run api:gen # SDK 재생성 (orval)
```
### Docker (개발)
[Dockerfile.dev](Dockerfile.dev) + [docker-compose.yml](docker-compose.yml) — Vite dev 서버를 컨테이너에서 돌리고 호스트 소스 변경을 HMR로 반영.
```bash
docker compose up # 시작
docker compose up --build # 의존성 바뀐 경우
docker compose down # 종료
```
## 프로젝트 구조
```
src/
├── app/ # 라우터 / 프로바이더 / 진입점 셋업
├── config/ # 앱 전역 설정
├── features/ # 도메인별 모듈 (페이지 + 훅 + 컴포넌트 + 데이터)
│ ├── admin/ # 관리자 (API 대시보드 등)
│ ├── auth/ # 인증
│ ├── channels/ # 채널 검증 / enrichment
│ ├── clinics/ # 병원 프로필
│ ├── dev/ # 개발용 페이지
│ ├── distribution/ # 콘텐츠 배포
│ ├── landing/ # 랜딩
│ ├── performance/ # 성과 대시보드
│ ├── plan/ # 마케팅 플랜
│ ├── pricing/ # 요금제
│ ├── report/ # 분석 리포트 (로딩/뷰어)
│ └── studio/ # 콘텐츠 스튜디오
├── shared/
│ ├── api/ # orval-generated SDK + HTTP 어댑터
│ │ ├── api.ts # customFetcher (ky 기반 mutator)
│ │ ├── generated/ # ⚠️ orval 자동생성 (직접 수정 금지)
│ │ └── model/ # ⚠️ orval 자동생성 타입
│ ├── constants/ # 전역 상수
│ ├── hooks/ # 범용 훅
│ ├── icons/ # 커스텀 아이콘
│ ├── layouts/ # 공통 레이아웃
│ ├── lib/ # 유틸 헬퍼
│ ├── types/ # 공통 타입
│ ├── ui/ # shadcn 기반 공용 컴포넌트
│ └── utils/ # 유틸 함수
├── styles/
│ └── index.css # ★ Tailwind + 디자인 토큰 (브랜드 색 / 폰트 / radius)
└── assets/ # 정적 자산 (폰트, 이미지 등)
```
기능 모듈 내부 컨벤션:
```
features/<name>/
├── pages/ # 라우트에 매핑되는 페이지 컴포넌트
├── components/ # 해당 기능 전용 컴포넌트
├── hooks/ # 기능 전용 훅 (use<Feature>.ts)
├── lib/ # transform / parser 등 순수 로직
├── data/ # mock / 시드 상수
├── types/ # 기능 전용 타입
└── routes.tsx # 라우트 정의
```
## 디자인 토큰
전부 [src/styles/index.css](src/styles/index.css)에서 관리. Tailwind v4의 CSS-first 방식이라 별도 `tailwind.config.js` 파일이 없다.
| 블록 | 위치 | 용도 |
| --- | --- | --- |
| `:root { --brand-* }` | 상단 | 브랜드 색 원본 (purple / navy / rose / earth …) |
| `:root { --status-* }` | 중간 | semantic 상태 색 (critical / warning / good / info) |
| `:root { --background, --primary, … }` | shadcn 영역 | shadcn 토큰 → 브랜드 색 매핑 |
| `.dark { … }` | 다크모드 | 다크 모드 오버라이드 |
| `@theme inline { --color-*, --font-* }` | 하단 | Tailwind 유틸리티로 노출 (`bg-brand-purple`, `text-status-critical-text` 등) |
새 색을 만들고 싶다면 `:root``--brand-xxx` 추가 → `@theme inline``--color-brand-xxx: var(--brand-xxx)` 추가 → `bg-brand-xxx` 유틸로 사용.
> **`tailwind.config.js` 가 없나요?**
> Tailwind v4 부터 설정을 JS 파일이 아닌 CSS의 `@theme` 블록에서 한다. 폰트, 색, radius, breakpoint 같은 모든 토큰을 [src/styles/index.css](src/styles/index.css) 하나에서 관리한다.
## 커스텀 클래스 / CSS
| 위치 | 내용 |
| --- | --- |
| [src/styles/custom.css](src/styles/custom.css) | 커스텀 유틸 클래스(`.text-gradient`, `.glass-card`, `.gradient-bg`, `.soft-gradient`) + `@keyframes` + 애니메이션 유틸(`.animate-blob`, `.animation-delay-*`) |
| [src/styles/index.css](src/styles/index.css) `@layer base` | 전역 element 스타일 (`body`, `h1`~`h6` 등) |
새 유틸 클래스는 `custom.css` 에 추가. 컴포넌트 단위 스타일은 Tailwind 유틸리티로 JSX에서 직접 작성하는 게 기본.
## 폰트 변경
Google Fonts로 가져오는 방식. 두 곳만 수정하면 된다.
1. **[index.html](index.html)** — Google Fonts `<link>``family=` 파라미터에 원하는 폰트 추가/교체
```html
<link href="https://fonts.googleapis.com/css2?family=원하는폰트:wght@400;700&display=swap" rel="stylesheet">
```
2. **[src/styles/index.css](src/styles/index.css)** 의 `@theme inline` 에서 `--font-sans` (본문) 또는 `--font-serif` (제목) 의 첫 항목을 새 폰트 이름으로 교체
## 백엔드 API / orval
OpenAPI 스펙을 가져와 SDK(React Query 훅 포함)를 자동생성한다.
- 입력: [orval.config.ts](orval.config.ts) 의 `input` 필드 (예: `http://localhost:8001/openapi.json`)
- 출력: `src/shared/api/generated/` (함수 + 훅) + `src/shared/api/model/` (타입)
- HTTP 어댑터: [src/shared/api/api.ts](src/shared/api/api.ts) — ky 기반 `customFetcher`. 4xx/5xx도 throw하지 않고 `{ status, data, headers }`로 반환.
### 사용 예
```ts
// 쿼리 훅
import { useGetReport } from '@/shared/api/generated/reports/reports'
const { data, isLoading } = useGetReport(id, { query: { enabled: !!id } })
// 뮤테이션 훅
import { useStartAnalysis } from '@/shared/api/generated/analyses/analyses'
const { mutateAsync } = useStartAnalysis()
await mutateAsync({ data: { url: '...' } })
// imperative 호출(폴링 등)
import { getAnalysisStatus } from '@/shared/api/generated/analyses/analyses'
const res = await getAnalysisStatus(runId)
```
### 재생성
백엔드 스펙이 바뀌면:
```bash
npm run api:gen
```
`clean: true``generated/`, `model/` 폴더는 매번 비워지고 새로 생성된다. 직접 수정 금지.
## API 프록시 (개발)
[vite.config.ts](vite.config.ts) 에서 `/api/*` 요청을 백엔드로 프록시한다.
- 기본 타겟: `http://localhost:8001`
- 도커 안에서 dev 서버 돌 때: `http://host.docker.internal:8001` (CHOKIDAR_USEPOLLING=true 일 때 자동)
- 오버라이드: `VITE_API_TARGET=http://40.82.133.44:8001` 환경변수
## shadcn/ui
[ui.shadcn.com](https://ui.shadcn.com/) 컴포넌트는 패키지가 아니라 **소스 복사** 방식으로 사용한다. 추가/수정한 컴포넌트는 [src/shared/ui/](src/shared/ui/) 에 위치하며, Tailwind 토큰(`--background`, `--primary` 등)을 통해 디자인 시스템과 연결된다.
새 컴포넌트 추가:
```bash
npx shadcn@latest add button
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/utils",
"ui": "@/shared/ui",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
},
"iconLibrary": "lucide"
}

24
docker-compose.yml Normal file
View File

@ -0,0 +1,24 @@
# 개발용 docker-compose — vite dev 서버 (HMR 동작)
# 사용:
# docker compose up # 서버 시작
# docker compose up --build # 의존성 바뀌면 빌드부터
# docker compose down # 종료
services:
frontend:
build:
context: .
dockerfile: Dockerfile.dev
container_name: o2o-infinith-frontend-dev
ports:
# 호스트의 3000은 ssh, 3001은 로컬 vite가 점유 중이라 3002로 매핑
- "3002:3000"
volumes:
# 소스 변경이 컨테이너로 즉시 반영
- .:/app
# node_modules는 호스트의 빈 폴더로 덮이지 않게 anonymous volume으로 보호
- /app/node_modules
environment:
- CHOKIDAR_USEPOLLING=true
- WATCHPACK_POLLING=true
restart: unless-stopped

20
index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>INFINITH - Infinite Marketing</title>
<!-- Self-hosted Pretendard (preload for FOUT 방지) -->
<link rel="preload" href="/fonts/PretendardVariable.woff2" as="font" type="font/woff2" crossorigin />
<!-- Google Fonts: Inter (영문 sans) + Playfair Display (영문 serif) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,900;1,400&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

15
nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

41
orval.config.ts Normal file
View File

@ -0,0 +1,41 @@
import { defineConfig } from 'orval'
export default defineConfig({
api: {
// sdk 파일 및 모델 가져올 swagger 서버 주소
input: 'http://40.82.133.44:8001/openapi.json',
output: {
mode: 'tags-split',
target: './src/shared/api/generated',
schemas: './src/shared/api/model',
client: 'react-query',
httpClient: 'fetch',
clean: true,
prettier: true,
override: {
mutator: {
path: './src/shared/api/api.ts',
name: 'customFetcher',
},
query: {
useQuery: true,
useMutation: true,
// orval 7.21이 fetch httpClient + queryFn 조합에서 signal을 잘못 넘기는 버그가 있어 비활성
signal: false,
options: {
staleTime: 60_000,
},
},
operationName: (operation) => {
// FastAPI 기본 operationId: <controller_name>_<path>_<verb>\
// 컨트롤러 함수명만 사용하기
const opId = operation.operationId ?? ''
const verbPattern = /_(get|post|put|delete|patch|options|head)$/i
const beforeApi = opId.split(/_api_/)[0]
const name = beforeApi !== opId ? beforeApi : opId.replace(verbPattern, '')
return name.replace(/_+([a-zA-Z])/g, (_, c) => c.toUpperCase())
},
},
},
},
})

9223
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "o2o-infinith-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"lint": "tsc --noEmit",
"api:gen": "orval --config ./orval.config.ts"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-query": "^5.59.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"html2canvas-pro": "^2.0.2",
"jspdf": "^4.2.1",
"ky": "^1.7.5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.13.1",
"tailwind-merge": "^2.5.5",
"zustand": "^5.0.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query-devtools": "^5.59.0",
"@types/node": "^22.14.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.0.4",
"orval": "^7.3.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.2.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

Binary file not shown.

14
src/app/main.tsx Normal file
View File

@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router'
import { router } from './router'
import { Providers } from './providers'
import '@/styles/index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Providers>
<RouterProvider router={router} />
</Providers>
</StrictMode>,
)

25
src/app/providers.tsx Normal file
View File

@ -0,0 +1,25 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState, type ReactNode } from 'react'
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
{children}
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</QueryClientProvider>
)
}

39
src/app/router.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Suspense } from 'react'
import { createBrowserRouter } from 'react-router'
import { Layout } from '@/shared/layouts/Layout'
import { landingRoutes } from '@/features/landing/routes'
import { pricingRoutes } from '@/features/pricing/routes'
import { authRoutes } from '@/features/auth/routes'
import { reportRoutes } from '@/features/report/routes'
import { planRoutes } from '@/features/plan/routes'
import { studioRoutes } from '@/features/studio/routes'
import { channelsRoutes } from '@/features/channels/routes'
import { distributionRoutes } from '@/features/distribution/routes'
import { performanceRoutes } from '@/features/performance/routes'
import { clinicsRoutes } from '@/features/clinics/routes'
import { adminRoutes } from '@/features/admin/routes'
import { devRoutes } from '@/features/dev/routes'
export const router = createBrowserRouter([
{
element: (
<Suspense fallback={null}>
<Layout />
</Suspense>
),
children: [
...landingRoutes,
...pricingRoutes,
...authRoutes,
...reportRoutes,
...planRoutes,
...studioRoutes,
...channelsRoutes,
...distributionRoutes,
...performanceRoutes,
...clinicsRoutes,
...adminRoutes,
...devRoutes,
],
},
])

View File

@ -0,0 +1,623 @@
import { useState, useEffect, type ReactNode } from 'react';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import {
ShieldFilled, DatabaseFilled, ServerFilled, BoltFilled,
EyeFilled, EyeOffFilled, CopyFilled, CheckFilled, CrossFilled,
WarningFilled, RefreshFilled, FlowFilled, CoinFilled,
LinkExternalFilled, GlobeFilled, PrismFilled,
YoutubeFilled, VideoFilled, MegaphoneFilled,
} from '@/shared/icons/FilledIcons';
/* ───────────────────────────── 타입 ───────────────────────────── */
type ApiStatus = 'connected' | 'missing' | 'error';
type ApiLayer = 'frontend' | 'edge-function' | 'both';
interface ApiConfig {
id: string;
name: string;
description: string;
envKeys: string[];
layer: ApiLayer;
docsUrl: string;
IconComponent: (props: { size?: number; className?: string }) => ReactNode;
category: 'ai' | 'scraping' | 'social' | 'platform';
pricingModel: string;
usedInPhases: string[];
estimatedCostPerRun?: string;
rateLimit?: string;
notes?: string;
}
interface ApiStatusInfo extends ApiConfig {
status: ApiStatus;
keyPresent: Record<string, boolean>;
}
/* ──────────────────── API 레지스트리 (단일 진실 공급원) ──────────── */
const API_REGISTRY: ApiConfig[] = [
// TODO(migration): 백엔드 플랫폼 (FastAPI) 의 메타 정보는 별도 엔드포인트가 없어 레지스트리에서 제외.
{
id: 'perplexity',
name: 'Perplexity AI',
description: 'LLM 기반 시장 분석 및 리포트 생성',
envKeys: ['PERPLEXITY_API_KEY'],
layer: 'edge-function',
docsUrl: 'https://docs.perplexity.ai',
IconComponent: PrismFilled,
category: 'ai',
pricingModel: 'Sonar: $1/1K requests',
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Phase 3: generate'],
estimatedCostPerRun: '~$0.05-0.15',
rateLimit: '50 RPM (rate limit)',
notes: 'discover(2회) + collect(4회 병렬) + generate(1회) = 분석당 ~7회 호출',
},
{
id: 'firecrawl',
name: 'Firecrawl',
description: '웹 스크래핑, 스크린샷 캡처, 구조화 데이터 추출',
envKeys: ['FIRECRAWL_API_KEY'],
layer: 'edge-function',
docsUrl: 'https://docs.firecrawl.dev',
IconComponent: GlobeFilled,
category: 'scraping',
pricingModel: 'Free 500 credits → $19/mo',
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
estimatedCostPerRun: '~5-10 credits',
rateLimit: '3 RPM (free), 20 RPM (paid)',
notes: 'scrape + map + search + fullPage screenshot',
},
{
id: 'apify',
name: 'Apify',
description: 'Instagram/Facebook 데이터 수집',
envKeys: ['APIFY_API_TOKEN'],
layer: 'edge-function',
docsUrl: 'https://docs.apify.com',
IconComponent: ServerFilled,
category: 'scraping',
pricingModel: 'Free $5/mo → Starter $49/mo',
usedInPhases: ['Phase 1: discover', 'Phase 2: collect', 'Enrich'],
estimatedCostPerRun: '~$0.10-0.30',
rateLimit: 'Actor-dependent (30-120s timeout)',
notes: 'IG profile/posts/reels + FB pages actors',
},
{
id: 'google-places',
name: 'Google Places API (New)',
description: 'Google Maps 장소 검색, 평점, 리뷰, 연락처 데이터',
envKeys: ['GOOGLE_PLACES_API_KEY'],
layer: 'edge-function',
docsUrl: 'https://developers.google.com/maps/documentation/places/web-service',
IconComponent: GlobeFilled,
category: 'social',
pricingModel: '$200/mo 무료 크레딧 포함',
usedInPhases: ['Phase 2: collect', 'Enrich', 'Registry enrichment'],
estimatedCostPerRun: '~$0.04',
rateLimit: 'No hard limit (pay-as-you-go)',
notes: 'Text Search + Place Details (1-2초 응답, Apify 대비 30x 빠름)',
},
{
id: 'youtube',
name: 'YouTube Data API v3',
description: 'YouTube 채널 검색, 통계, 영상 데이터',
envKeys: ['YOUTUBE_API_KEY'],
layer: 'edge-function',
docsUrl: 'https://developers.google.com/youtube/v3',
IconComponent: YoutubeFilled,
category: 'social',
pricingModel: 'Free (10,000 units/day)',
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
estimatedCostPerRun: '~200-400 units',
rateLimit: '10,000 units/day',
notes: 'search(100u) + channels(5u) + videos(5u each)',
},
{
id: 'naver',
name: 'Naver Search API',
description: '네이버 블로그/웹/플레이스 검색',
envKeys: ['NAVER_CLIENT_ID', 'NAVER_CLIENT_SECRET'],
layer: 'edge-function',
docsUrl: 'https://developers.naver.com/docs/serviceapi/search/blog/blog.md',
IconComponent: MegaphoneFilled,
category: 'social',
pricingModel: 'Free (25,000/day)',
usedInPhases: ['Phase 1: discover', 'Phase 2: collect'],
estimatedCostPerRun: 'Free',
rateLimit: '25,000 calls/day',
notes: 'blog.json + webkr.json + local.json',
},
{
id: 'gemini',
name: 'Google Gemini',
description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
envKeys: ['GEMINI_API_KEY'],
layer: 'both',
docsUrl: 'https://ai.google.dev/docs',
IconComponent: DatabaseFilled,
category: 'ai',
pricingModel: 'Free tier → Pay-as-you-go',
usedInPhases: ['Phase 2: collect (Vision)', 'Studio (Image Gen)'],
estimatedCostPerRun: '~$0.01-0.05',
rateLimit: '15 RPM (free), 1000 RPM (paid)',
notes: 'gemini-2.5-flash-image 모델 사용',
},
{
id: 'creatomate',
name: 'Creatomate',
description: '마케팅 영상 자동 생성 (템플릿 기반)',
envKeys: ['VITE_CREATOMATE_API_KEY'],
layer: 'frontend',
docsUrl: 'https://creatomate.com/docs/api/introduction',
IconComponent: VideoFilled,
category: 'ai',
pricingModel: 'Free 5 renders → Pro $39/mo',
usedInPhases: ['Studio'],
estimatedCostPerRun: '1 render credit',
rateLimit: '2 concurrent renders (free)',
notes: 'POST /renders → poll until complete',
},
];
/* ────────────────── 환경변수 체커 ─────────────── */
function checkEnvVars(): ApiStatusInfo[] {
return API_REGISTRY.map((api) => {
const keyPresent: Record<string, boolean> = {};
for (const key of api.envKeys) {
if (key.startsWith('VITE_')) {
const val = (import.meta.env as Record<string, string | undefined>)[key];
keyPresent[key] = !!val && val.length > 0;
} else {
keyPresent[key] = true;
}
}
const allPresent = Object.values(keyPresent).every(Boolean);
const nonePresent = Object.values(keyPresent).every((v) => !v);
return {
...api,
status: nonePresent ? 'missing' : allPresent ? 'connected' : 'error',
keyPresent,
};
});
}
/* ──────────────────────── 서브 컴포넌트 ──────────────────────── */
function StatusDot({ status }: { status: ApiStatus }) {
const styles = {
connected: 'bg-status-good-dot',
missing: 'bg-status-critical-dot',
error: 'bg-status-warning-dot',
};
return (
<span className="relative flex h-3 w-3">
{status === 'connected' && (
<span className={`animate-ping absolute inline-flex h-full w-full rounded-full opacity-40 ${styles[status]}`} />
)}
<span className={`relative inline-flex rounded-full h-3 w-3 ${styles[status]}`} />
</span>
);
}
function StatusBadge({ status }: { status: ApiStatus }) {
const config = {
connected: { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Connected', Icon: CheckFilled },
missing: { bg: 'bg-status-critical-bg', text: 'text-status-critical-text', border: 'border-status-critical-border', label: 'Missing Key', Icon: CrossFilled },
error: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Partial', Icon: WarningFilled },
};
const c = config[status];
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-semibold rounded-full border ${c.bg} ${c.text} ${c.border}`}>
<c.Icon size={12} className={c.text} />
{c.label}
</span>
);
}
function LayerBadge({ layer }: { layer: ApiLayer }) {
const config = {
frontend: { bg: 'bg-status-info-bg', text: 'text-status-info-text', border: 'border-status-info-border', label: 'Frontend', Icon: GlobeFilled },
'edge-function': { bg: 'bg-status-good-bg', text: 'text-status-good-text', border: 'border-status-good-border', label: 'Edge Function', Icon: ServerFilled },
both: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', border: 'border-status-warning-border', label: 'Both', Icon: BoltFilled },
};
const c = config[layer];
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-[11px] font-medium rounded-md border ${c.bg} ${c.text} ${c.border}`}>
<c.Icon size={12} className={c.text} />
{c.label}
</span>
);
}
function CategoryBadge({ category }: { category: ApiConfig['category'] }) {
const config = {
ai: { bg: 'bg-status-good-bg', text: 'text-status-good-text', label: 'AI' },
scraping: { bg: 'bg-status-warning-bg', text: 'text-status-warning-text', label: 'Scraping' },
social: { bg: 'bg-status-info-bg', text: 'text-status-info-text', label: 'Social' },
platform: { bg: 'bg-primary-50', text: 'text-primary-800', label: 'Platform' },
};
const c = config[category];
return (
<span className={`px-2 py-0.5 text-[11px] font-medium rounded-md ${c.bg} ${c.text}`}>
{c.label}
</span>
);
}
function EnvKeyRow({ envKey, present, isServerSide }: { envKey: string; present: boolean; isServerSide: boolean }) {
const [showKey, setShowKey] = useState(false);
const [copied, setCopied] = useState(false);
const value = isServerSide
? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
: showKey
? (import.meta.env as Record<string, string | undefined>)[envKey] || ''
: '••••••••••••••••';
const handleCopy = () => {
if (!isServerSide) {
navigator.clipboard.writeText((import.meta.env as Record<string, string | undefined>)[envKey] || '');
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
};
return (
<div className="flex items-center gap-2 py-1.5 px-3 rounded-lg bg-primary-50 text-xs font-mono group">
<StatusDot status={present ? 'connected' : 'missing'} />
<span className="text-slate-500 min-w-[200px]">{envKey}</span>
<span className="text-slate-400 flex-1 truncate">{value}</span>
{!isServerSide && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => setShowKey(!showKey)} className="p-1 hover:bg-slate-200 rounded">
{showKey
? <EyeOffFilled size={14} className="text-slate-400" />
: <EyeFilled size={14} className="text-slate-400" />}
</button>
<button onClick={handleCopy} className="p-1 hover:bg-slate-200 rounded">
{copied
? <CheckFilled size={14} className="text-status-good-dot" />
: <CopyFilled size={14} className="text-slate-400" />}
</button>
</div>
)}
</div>
);
}
function ApiCard({ api, index }: { api: ApiStatusInfo; index: number }) {
const [expanded, setExpanded] = useState(false);
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full px-6 py-5 flex items-center gap-4 text-left"
>
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<api.IconComponent size={20} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-serif font-bold text-primary-900 text-base">{api.name}</h3>
<StatusDot status={api.status} />
</div>
<p className="text-xs text-slate-500 truncate">{api.description}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<CategoryBadge category={api.category} />
<LayerBadge layer={api.layer} />
<StatusBadge status={api.status} />
</div>
</button>
{/* Expandable detail */}
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
transition={{ duration: 0.2 }}
className="border-t border-slate-100 px-6 py-4 space-y-4"
>
{/* Env Keys */}
<div>
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">Environment Variables</h4>
<div className="space-y-1.5">
{api.envKeys.map((key) => (
<EnvKeyRow
key={key}
envKey={key}
present={api.keyPresent[key]}
isServerSide={!key.startsWith('VITE_')}
/>
))}
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Pricing</div>
<div className="text-xs font-semibold text-slate-700">{api.pricingModel}</div>
</div>
{api.estimatedCostPerRun && (
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Cost / Run</div>
<div className="text-xs font-semibold text-slate-700">{api.estimatedCostPerRun}</div>
</div>
)}
{api.rateLimit && (
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Rate Limit</div>
<div className="text-xs font-semibold text-slate-700">{api.rateLimit}</div>
</div>
)}
<div className="bg-primary-50 rounded-xl p-3">
<div className="text-[11px] text-slate-400 font-medium mb-1">Used In</div>
<div className="text-xs font-semibold text-slate-700">{api.usedInPhases.join(', ')}</div>
</div>
</div>
{/* Notes + Docs link */}
<div className="flex items-center justify-between">
{api.notes && (
<p className="text-xs text-slate-500 italic">{api.notes}</p>
)}
<a
href={api.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline flex-shrink-0"
>
API Docs <LinkExternalFilled size={12} className="text-accent" />
</a>
</div>
</motion.div>
)}
</motion.div>
);
}
/* ──────────────────── 파이프라인 플로우 다이어그램 ───────────────────── */
function PipelineFlow({ apis }: { apis: ApiStatusInfo[] }) {
const phases = [
{ name: 'Phase 1', label: 'Discover Channels', apis: ['firecrawl', 'youtube', 'naver', 'perplexity', 'apify'] },
{ name: 'Phase 2', label: 'Collect Data', apis: ['apify', 'youtube', 'firecrawl', 'naver', 'perplexity', 'gemini'] },
{ name: 'Phase 3', label: 'Generate Report', apis: ['perplexity'] },
{ name: 'Studio', label: 'Content Creation', apis: ['gemini', 'creatomate'] },
];
const apiMap = Object.fromEntries(apis.map((a) => [a.id, a]));
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="font-serif font-bold text-primary-900 text-base mb-5 flex items-center gap-2">
<FlowFilled size={18} className="text-accent" />
Pipeline API Flow
</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{phases.map((phase, idx) => (
<div key={phase.name} className="relative">
<div className="bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-xs font-bold px-3 py-1.5 rounded-t-xl">
{phase.name}: {phase.label}
</div>
<div className="border border-t-0 border-slate-200 rounded-b-xl p-3 space-y-1.5 min-h-[100px]">
{phase.apis.map((apiId) => {
const api = apiMap[apiId];
if (!api) return null;
return (
<div key={apiId} className="flex items-center gap-2 text-xs">
<StatusDot status={api.status} />
<api.IconComponent size={14} className="text-slate-400" />
<span className="text-slate-600">{api.name}</span>
</div>
);
})}
</div>
{idx < phases.length - 1 && (
<div className="hidden md:flex absolute -right-3 top-1/2 transform -translate-y-1/2 z-10">
<ArrowRight className="w-4 h-4 text-slate-300" />
</div>
)}
</div>
))}
</div>
</div>
);
}
/* ────────────────────── 비용 추정기 ────────────────────────── */
function CostEstimator({ apis }: { apis: ApiStatusInfo[] }) {
const costItems = apis
.filter((a) => a.estimatedCostPerRun)
.map((a) => ({
name: a.name,
IconComponent: a.IconComponent,
cost: a.estimatedCostPerRun!,
}));
return (
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<h3 className="font-serif font-bold text-primary-900 text-base mb-4 flex items-center gap-2">
<CoinFilled size={18} className="text-accent" />
Estimated Cost per Analysis Run
</h3>
<div className="space-y-2">
{costItems.map((item) => (
<div key={item.name} className="flex items-center justify-between py-2 px-3 bg-primary-50 rounded-xl">
<span className="text-sm text-slate-700 flex items-center gap-2">
<item.IconComponent size={16} className="text-slate-400" />
{item.name}
</span>
<span className="text-sm font-bold text-primary-900">{item.cost}</span>
</div>
))}
<div className="flex items-center justify-between py-3 px-3 bg-accent/5 rounded-xl border border-accent/20 mt-3">
<span className="text-sm font-bold text-primary-900">Total (estimated)</span>
<span className="text-sm font-black text-accent">~$0.26-0.80 / run</span>
</div>
</div>
</div>
);
}
/* ──────────────────────── 메인 페이지 ───────────────────────────── */
export default function ApiDashboardPage() {
const [apis, setApis] = useState<ApiStatusInfo[]>([]);
const [lastChecked, setLastChecked] = useState<Date | null>(null);
const [filter, setFilter] = useState<'all' | 'connected' | 'missing'>('all');
const refreshStatus = () => {
setApis(checkEnvVars());
setLastChecked(new Date());
};
useEffect(() => {
refreshStatus();
}, []);
const connectedCount = apis.filter((a) => a.status === 'connected').length;
const missingCount = apis.filter((a) => a.status === 'missing').length;
const errorCount = apis.filter((a) => a.status === 'error').length;
const filteredApis = filter === 'all' ? apis : apis.filter((a) => a.status === filter);
return (
<div className="min-h-screen bg-slate-50 pt-28 pb-16 px-6">
<div className="max-w-7xl mx-auto space-y-8">
{/* Page Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex items-center gap-3 mb-2">
<div className="p-2.5 bg-accent/10 rounded-xl">
<ShieldFilled size={24} className="text-accent" />
</div>
<div>
<h1 className="font-serif text-3xl font-bold text-primary-900">API Dashboard</h1>
<p className="text-sm text-slate-500">API , , , </p>
</div>
</div>
</motion.div>
{/* Summary Cards */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<DatabaseFilled size={16} className="text-slate-400" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Total APIs</span>
</div>
<div className="text-3xl font-black text-primary-900">{apis.length}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<CheckFilled size={16} className="text-status-good-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Connected</span>
</div>
<div className="text-3xl font-black text-status-good-text">{connectedCount}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<CrossFilled size={16} className="text-status-critical-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Missing</span>
</div>
<div className="text-3xl font-black text-status-critical-text">{missingCount}</div>
</div>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5">
<div className="flex items-center gap-2 mb-2">
<WarningFilled size={16} className="text-status-warning-dot" />
<span className="text-xs font-medium text-slate-400 uppercase tracking-wider">Partial</span>
</div>
<div className="text-3xl font-black text-status-warning-text">{errorCount}</div>
</div>
</motion.div>
{/* Pipeline Flow */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<PipelineFlow apis={apis} />
</motion.div>
{/* Filter + Refresh */}
<div className="flex items-center justify-between">
<div className="flex gap-2">
{(['all', 'connected', 'missing'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-5 py-2.5 text-xs font-semibold rounded-full transition-all ${
filter === f
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
: 'bg-white text-slate-500 border border-slate-200 hover:bg-slate-50'
}`}
>
{f === 'all' ? 'All' : f === 'connected' ? 'Connected' : 'Missing'}
</button>
))}
</div>
<div className="flex items-center gap-3">
{lastChecked && (
<span className="text-xs text-slate-400">
Last checked: {lastChecked.toLocaleTimeString('ko-KR')}
</span>
)}
<button
onClick={refreshStatus}
className="inline-flex items-center gap-1.5 px-5 py-2.5 text-xs font-semibold bg-white border border-slate-200 rounded-full hover:bg-slate-50 transition-all text-slate-600"
>
<RefreshFilled size={14} className="text-slate-500" />
Refresh
</button>
</div>
</div>
{/* API Cards */}
<div className="space-y-3">
{filteredApis.map((api, idx) => (
<ApiCard key={api.id} api={api} index={idx} />
))}
</div>
{/* Cost Estimator */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<CostEstimator apis={apis} />
</motion.div>
{/* Footer note */}
<div className="text-center text-xs text-slate-400 pt-4">
<PrismFilled size={14} className="text-slate-300 inline mr-1" />
VITE_ . .
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,565 @@
import { motion } from 'motion/react';
import {
GlobeFilled,
YoutubeFilled,
InstagramFilled,
} from '@/shared/icons/FilledIcons';
import {
Check,
X,
Globe,
Database,
Sparkles,
CheckCircle,
ArrowRight,
AlertTriangle,
ShieldCheck,
Ban,
Server,
} from 'lucide-react';
import type { ComponentType } from 'react';
// ─── Types ───
type SiteStatus = 'success' | 'partial' | 'blocked';
interface DataPoint {
field: string;
value: string;
collected: boolean;
}
interface SiteResult {
name: string;
url: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
status: SiteStatus;
statusLabel: string;
crawlMethod: string;
creditsUsed: number;
dataPoints: DataPoint[];
rawResponse: string;
}
// ─── Firecrawl 실제 테스트 결과 (2026-03-30) ───
const SITE_RESULTS: SiteResult[] = [
{
name: 'viewclinic.com',
url: 'https://www.viewclinic.com',
icon: GlobeFilled,
brandColor: '#7B2D8E',
status: 'success',
statusLabel: '수집 성공',
crawlMethod: 'Firecrawl Extract',
creditsUsed: 5,
dataPoints: [
{ field: '병원명 (한)', value: '뷰성형외과', collected: true },
{ field: '병원명 (영)', value: 'VIEW PLASTIC SURGERY', collected: true },
{ field: '전화번호', value: '02.539.1177', collected: true },
{ field: '주소', value: '서울 강남구 봉은사로 107', collected: true },
{ field: 'SNS 링크', value: 'KakaoTalk, YouTube 발견', collected: true },
{ field: 'Facebook Pixel', value: 'ID: 299151214739571', collected: true },
{ field: 'Google Tag Manager', value: 'GTM-52RT6DMK', collected: true },
{ field: 'Kakao Pixel', value: 'ID: 5684247168888976904', collected: true },
{ field: 'OG 이미지', value: 'thumbnail_230126.jpg', collected: true },
{ field: '네이버 사이트 인증', value: '2개 인증 키 확인', collected: true },
{ field: '브랜드 컬러', value: '추출 실패 (CSS 파싱 한계)', collected: false },
{ field: '인증/수상 목록', value: '추출 실패 (동적 콘텐츠)', collected: false },
],
rawResponse: `{
"success": true,
"extract": {
"hospitalName": "뷰성형외과",
"hospitalNameEn": "VIEW PLASTIC SURGERY",
"phone": "02.539.1177",
"address": "서울 강남구 봉은사로 107 뷰성형외과 빌딩",
"snsLinks": [
{ "platform": "KakaoTalk", "url": "pf.kakao.com/_xbtVxjl" },
{ "platform": "YouTube", "url": "youtube.com/@ViewclinicKR" }
]
},
"creditsUsed": 5
}`,
},
{
name: 'YouTube',
url: 'https://www.youtube.com/@ViewclinicKR',
icon: YoutubeFilled,
brandColor: '#FF0000',
status: 'partial',
statusLabel: '부분 수집',
crawlMethod: 'Firecrawl Extract',
creditsUsed: 5,
dataPoints: [
{ field: '채널명', value: '뷰성형외과 VIEW Plastic Surgery', collected: true },
{ field: '채널 설명', value: 'og:description에서 추출', collected: true },
{ field: '프로필 이미지', value: 'yt3.googleusercontent.com/...', collected: true },
{ field: '채널 키워드', value: '19개 태그 (뷰성형외과, 코성형 등)', collected: true },
{ field: '채널 ID', value: 'UCQqqH3Klj2HQSHNNSVug-CQ', collected: true },
{ field: '구독자 수', value: 'Not available', collected: false },
{ field: '총 영상 수', value: 'Not available', collected: false },
{ field: '총 조회수', value: 'Not available', collected: false },
{ field: '최근 영상 목록', value: 'JS 렌더링 불가', collected: false },
{ field: '업로드 빈도', value: 'JS 렌더링 불가', collected: false },
],
rawResponse: `{
"success": true,
"extract": {
"channelName": "뷰성형외과 VIEW Plastic Surgery",
"subscribers": "Not available",
"totalVideos": "Not available",
"description": "View Plastic Surgery channel..."
},
"metadata": {
"og:image": "yt3.googleusercontent.com/NPq6T...",
"og:video:tag": ["뷰성형외과", "성형", "코성형", ...]
},
"creditsUsed": 5
}`,
},
{
name: 'Instagram',
url: 'https://www.instagram.com/viewplastic/',
icon: InstagramFilled,
brandColor: '#E1306C',
status: 'blocked',
statusLabel: '공식 차단',
crawlMethod: 'Firecrawl (차단됨)',
creditsUsed: 0,
dataPoints: [
{ field: '핸들', value: '@viewplastic', collected: false },
{ field: '팔로워 수', value: '수집 불가', collected: false },
{ field: '팔로잉 수', value: '수집 불가', collected: false },
{ field: '게시물 수', value: '수집 불가', collected: false },
{ field: '바이오', value: '수집 불가', collected: false },
{ field: '프로필 사진', value: '수집 불가', collected: false },
{ field: 'Reels 수', value: '수집 불가', collected: false },
{ field: '하이라이트', value: '수집 불가', collected: false },
],
rawResponse: `{
"success": false,
"error": "We do not support this site.
If you are part of an enterprise,
please fill out our intake form."
}`,
},
{
name: '강남언니',
url: 'https://www.gangnamunni.com/hospitals/189',
icon: GlobeFilled,
brandColor: '#03C75A',
status: 'success',
statusLabel: '수집 성공',
crawlMethod: 'Firecrawl Extract',
creditsUsed: 5,
dataPoints: [
{ field: '병원명', value: '뷰성형외과의원', collected: true },
{ field: '평점', value: '9.5 / 10', collected: true },
{ field: '총 리뷰', value: '18,961건', collected: true },
{ field: '의료진 수', value: '25명', collected: true },
{ field: '대표원장 (최순우)', value: '평점 9.4, 리뷰 1,812건', collected: true },
{ field: '원장 (윤창운)', value: '평점 9.6, 리뷰 764건', collected: true },
{ field: '원장 (김정민)', value: '평점 9.7, 리뷰 878건', collected: true },
{ field: '인증 뱃지', value: '10개 (수술실 CCTV, 마취과 상주 등)', collected: true },
{ field: '시술 가격 (가슴성형)', value: '₩2,650,000~', collected: true },
{ field: '시술 가격 (눈성형)', value: '₩440,000~', collected: true },
{ field: '시술 가격 (코성형)', value: '₩990,000~', collected: true },
{ field: '시술 가격 (안면윤곽)', value: '₩3,289,000~', collected: true },
],
rawResponse: `{
"success": true,
"extract": {
"hospitalName": "뷰성형외과의원",
"rating": "9.5",
"totalReviews": "18,961",
"doctorCount": "25",
"doctors": [
{ "name": "최순우 대표원장", "rating": "9.4", "reviewCount": "1,812" },
{ "name": "윤창운 원장", "rating": "9.6", "reviewCount": "764" },
{ "name": "김정민 원장", "rating": "9.7", "reviewCount": "878" }
],
"certifications": ["수술실 CCTV", "마취과 전문의 상주", ...],
"procedures": [
{ "name": "가슴성형", "price": "2,650,000원" },
{ "name": "눈성형", "price": "440,000원" }
]
},
"creditsUsed": 5
}`,
},
];
// ─── Process Steps ───
const CRAWL_STEPS = [
{ icon: Globe, label: 'URL 입력', desc: '대상 병원 웹사이트 URL' },
{ icon: Server, label: 'Firecrawl 크롤링', desc: '웹사이트 + 리뷰 플랫폼 스크래핑' },
{ icon: Database, label: 'API 수집', desc: 'YouTube Data API, Apify' },
{ icon: Sparkles, label: 'AI 분석', desc: 'Claude가 진단 + 전략 생성' },
{ icon: CheckCircle, label: '리포트 완성', desc: '멀티소스 데이터 기반 리포트' },
];
// ─── Coverage Data ───
const COVERAGE = [
{ method: 'Firecrawl', desc: '웹사이트, 강남언니, SSR 플랫폼', pct: 35, color: '#6C5CE7' },
{ method: 'API 연동', desc: 'YouTube Data API, Apify (Instagram/Facebook)', pct: 45, color: '#9B8AD4' },
{ method: 'AI 분석', desc: 'Claude — 진단, 전략, 로드맵, KPI 생성', pct: 20, color: '#D4A872' },
];
// ─── Alternatives for blocked data ───
const ALTERNATIVES = [
{ blocked: 'Instagram 프로필 데이터', alt: 'Apify Instagram Scraper', cost: '~₩65,000/월 (Starter)', priority: 'P0' },
{ blocked: 'YouTube 구독자/영상 통계', alt: 'YouTube Data API v3', cost: '무료 (일 10K units)', priority: 'P0' },
{ blocked: 'Facebook 페이지 데이터', alt: 'Apify Facebook Scraper', cost: 'Apify 포함', priority: 'P1' },
{ blocked: 'TikTok 계정 데이터', alt: 'Apify TikTok Scraper', cost: 'Apify 포함', priority: 'P2' },
{ blocked: '브랜드 컬러 자동 추출', alt: 'Brandfetch API / Vision API', cost: '~$50/월', priority: 'P2' },
];
// ─── Helpers ───
const statusConfig: Record<SiteStatus, { bg: string; text: string; border: string; dot: string; label: string }> = {
success: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]', dot: 'bg-[#9B8AD4]', label: '수집 성공' },
partial: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', border: 'border-[#F5E0C5]', dot: 'bg-[#D4A872]', label: '부분 수집' },
blocked: { bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', border: 'border-[#F5D5DC]', dot: 'bg-[#D4889A]', label: '차단됨' },
};
function successRate(points: DataPoint[]): number {
return Math.round((points.filter(p => p.collected).length / points.length) * 100);
}
// ─── Component ───
export default function DataValidationPage() {
return (
<div className="pt-20 min-h-screen">
{/* ── Header ── */}
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Data Collection Validation</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3"> </h1>
<p className="text-purple-200/70 max-w-xl mb-4">Firecrawl API </p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="px-3 py-1 rounded-full bg-white/10 text-purple-200">: 2026-03-30</span>
<span className="px-3 py-1 rounded-full bg-white/10 text-purple-200">API: Firecrawl v1</span>
<span className="px-3 py-1 rounded-full bg-white/10 text-purple-200"> : 15 credits</span>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-10 space-y-12">
{/* ── Section 1: 수집 프로세스 ── */}
<section>
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> </h2>
<p className="text-slate-500 text-sm mb-6">URL </p>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8">
<div className="flex flex-col md:flex-row items-start md:items-center gap-4 md:gap-0">
{CRAWL_STEPS.map((step, i) => {
const Icon = step.icon;
return (
<motion.div
key={step.label}
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.12 }}
className="flex items-center gap-3 md:flex-1"
>
<div className="flex items-center gap-3 flex-1">
<div className="w-11 h-11 rounded-xl bg-[#F3F0FF] flex items-center justify-center flex-shrink-0">
<Icon size={20} className="text-[#6C5CE7]" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-primary-900 truncate">{step.label}</p>
<p className="text-xs text-slate-400 truncate">{step.desc}</p>
</div>
</div>
{i < CRAWL_STEPS.length - 1 && (
<ArrowRight size={16} className="text-slate-300 hidden md:block flex-shrink-0 mx-2" />
)}
</motion.div>
);
})}
</div>
</div>
</section>
{/* ── Section 2: 사이트별 테스트 결과 ── */}
<section>
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> </h2>
<p className="text-slate-500 text-sm mb-6">Firecrawl Extract API 4 (2026-03-30 )</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{SITE_RESULTS.map((site, i) => {
const cfg = statusConfig[site.status];
const Icon = site.icon;
const rate = successRate(site.dataPoints);
return (
<motion.div
key={site.name}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
{/* Card Header */}
<div className="p-5 pb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${site.brandColor}12` }}>
<Icon size={22} className="opacity-80" />
</div>
<div>
<h3 className="font-semibold text-primary-900">{site.name}</h3>
<p className="text-xs text-slate-400">{site.crawlMethod} · {site.creditsUsed} credits</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${cfg.bg} ${cfg.text} border ${cfg.border}`}>
{site.statusLabel}
</span>
</div>
{/* Data Points */}
<div className="px-5 pb-2">
<div className="space-y-1.5 max-h-[260px] overflow-y-auto pr-1">
{site.dataPoints.map(dp => (
<div key={dp.field} className="flex items-center gap-2 text-sm">
{dp.collected ? (
<div className="w-5 h-5 rounded-full bg-[#F3F0FF] flex items-center justify-center flex-shrink-0">
<Check size={12} className="text-[#6C5CE7]" />
</div>
) : (
<div className="w-5 h-5 rounded-full bg-[#FFF0F0] flex items-center justify-center flex-shrink-0">
<X size={12} className="text-[#D4889A]" />
</div>
)}
<span className="text-slate-500 w-36 flex-shrink-0 truncate">{dp.field}</span>
<span className={`truncate ${dp.collected ? 'text-primary-900 font-medium' : 'text-slate-300'}`}>
{dp.value}
</span>
</div>
))}
</div>
</div>
{/* Progress Bar */}
<div className="px-5 pb-4 pt-3">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="text-slate-400"></span>
<span className={`font-bold ${cfg.text}`}>{rate}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${rate}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: i * 0.1 + 0.3 }}
className="h-full rounded-full"
style={{ backgroundColor: site.status === 'blocked' ? '#D4889A' : site.status === 'partial' ? '#D4A872' : '#9B8AD4' }}
/>
</div>
</div>
{/* Raw Response */}
<details className="border-t border-slate-100">
<summary className="px-5 py-3 text-xs text-slate-400 cursor-pointer hover:text-slate-600 transition-colors">
API
</summary>
<pre className="px-5 pb-4 text-xs text-slate-500 bg-slate-50 overflow-x-auto whitespace-pre-wrap font-mono leading-relaxed">
{site.rawResponse}
</pre>
</details>
</motion.div>
);
})}
</div>
</section>
{/* ── Section 3: 수집 불가 데이터 + 대안 ── */}
<section>
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> & </h2>
<p className="text-slate-500 text-sm mb-6">Firecrawl </p>
{/* Blocked Reason */}
<motion.div
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-[#FFF0F0] border border-[#F5D5DC] rounded-2xl p-5 mb-6"
>
<div className="flex items-start gap-3">
<Ban size={20} className="text-[#D4889A] mt-0.5 flex-shrink-0" />
<div>
<p className="font-semibold text-[#7C3A4B] mb-1">Instagram </p>
<p className="text-sm text-[#7C3A4B]/70">
Firecrawl Instagram (Meta ).
Facebook React SPA .
YouTube , / InnerTube API .
</p>
</div>
</div>
</motion.div>
{/* Alternatives Table */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-x-4 px-5 py-3 bg-slate-50 border-b border-slate-100 text-xs font-semibold text-slate-400 uppercase tracking-wider">
<span> </span>
<span> </span>
<span></span>
<span></span>
</div>
{ALTERNATIVES.map((alt, i) => (
<motion.div
key={alt.blocked}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.06 }}
className="grid grid-cols-[1fr_1fr_auto_auto] gap-x-4 px-5 py-3.5 border-b border-slate-50 last:border-b-0 items-center text-sm"
>
<span className="text-slate-600">{alt.blocked}</span>
<span className="text-primary-900 font-medium">{alt.alt}</span>
<span className="text-slate-400 text-xs whitespace-nowrap">{alt.cost}</span>
<span className={`px-2 py-0.5 rounded text-xs font-bold ${
alt.priority === 'P0' ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
alt.priority === 'P1' ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
'bg-slate-100 text-slate-500'
}`}>{alt.priority}</span>
</motion.div>
))}
</div>
</section>
{/* ── Section 4: 전체 커버리지 ── */}
<section>
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> </h2>
<p className="text-slate-500 text-sm mb-6">3 100% </p>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 md:p-8 space-y-5">
{COVERAGE.map((item, i) => (
<motion.div
key={item.method}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.15 }}
>
<div className="flex items-center justify-between mb-2">
<div>
<span className="font-semibold text-primary-900">{item.method}</span>
<span className="text-sm text-slate-400 ml-2">{item.desc}</span>
</div>
<span className="text-lg font-bold" style={{ color: item.color }}>{item.pct}%</span>
</div>
<div className="h-4 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${item.pct}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: i * 0.15 + 0.2 }}
className="h-full rounded-full"
style={{ backgroundColor: item.color }}
/>
</div>
</motion.div>
))}
{/* Stacked Bar */}
<div className="pt-4 border-t border-slate-100">
<p className="text-xs text-slate-400 mb-2 font-semibold uppercase tracking-wider">Combined Coverage</p>
<div className="h-6 rounded-full overflow-hidden flex">
{COVERAGE.map(item => (
<motion.div
key={item.method}
initial={{ width: 0 }}
whileInView={{ width: `${item.pct}%` }}
viewport={{ once: true }}
transition={{ duration: 1, delay: 0.5 }}
className="h-full flex items-center justify-center text-white text-xs font-bold"
style={{ backgroundColor: item.color }}
>
{item.pct}%
</motion.div>
))}
</div>
</div>
</div>
{/* Summary Insight */}
<motion.div
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.3 }}
className="mt-6 bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-6 md:p-8"
>
<div className="flex items-start gap-3">
<ShieldCheck size={24} className="text-[#6C5CE7] mt-0.5 flex-shrink-0" />
<div>
<p className="font-serif text-lg font-bold text-primary-900 mb-2"> 100% </p>
<p className="text-sm text-slate-600 leading-relaxed">
Instagram , YouTube <strong>Firecrawl(35%) + API(45%) + AI(20%)</strong>
.
Firecrawl SSR ,
Apify + API .
</p>
<div className="flex flex-wrap gap-3 mt-4">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/60 text-xs font-medium text-primary-900">
<AlertTriangle size={14} className="text-[#D4A872]" />
Firecrawl : ~35%
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/60 text-xs font-medium text-primary-900">
<CheckCircle size={14} className="text-[#6C5CE7]" />
P0 : ~95%
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/60 text-xs font-medium text-primary-900">
<Sparkles size={14} className="text-[#9B8AD4]" />
AI : 100%
</div>
</div>
</div>
</div>
</motion.div>
</section>
{/* ── Section 5: 비용 요약 ── */}
<section>
<h2 className="font-serif text-2xl font-bold text-primary-900 mb-2"> (MVP)</h2>
<p className="text-slate-500 text-sm mb-6">P0 ~141,000 </p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[
{ name: 'Firecrawl', cost: '~₩26,000', detail: '$19/mo Growth', role: '웹사이트 + SSR 크롤링' },
{ name: 'YouTube API', cost: '무료', detail: '일 10K units', role: 'YouTube 채널/영상 분석' },
{ name: 'Apify', cost: '~₩65,000', detail: '$49/mo Starter', role: 'Instagram/Facebook/TikTok' },
{ name: 'Claude API', cost: '~₩50,000', detail: 'Usage-based', role: '분석/진단/전략 생성' },
].map((item, i) => (
<motion.div
key={item.name}
initial={{ opacity: 0, y: 15 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5 text-center"
>
<p className="text-xs text-slate-400 mb-1">{item.name}</p>
<p className="text-xl font-bold text-primary-900 mb-0.5">{item.cost}</p>
<p className="text-xs text-slate-400 mb-3">{item.detail}</p>
<p className="text-xs text-[#6C5CE7] font-medium">{item.role}</p>
</motion.div>
))}
</div>
</section>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const ApiDashboardPage = lazy(() => import('./pages/ApiDashboardPage'))
const DataValidationPage = lazy(() => import('./pages/DataValidationPage'))
export const adminRoutes: RouteObject[] = [
{ path: 'api-dashboard', element: <ApiDashboardPage /> },
{ path: 'data-validation', element: <DataValidationPage /> },
]

View File

@ -0,0 +1,132 @@
/**
* LoginPage ().
*
* :
* - UI· . Supabase Auth .
* - , .
*
* :
* - Supabase `signInWithPassword`
* - (`useSession`) + ProtectedRoute
* -
*/
import { useState, type FormEvent } from 'react';
import { Link } from 'react-router';
import { ArrowRight } from 'lucide-react';
import { buildContactMailto } from '@/shared/lib/contact';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [notice, setNotice] = useState<string | null>(null);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setNotice(
'로그인 기능은 준비 중입니다. 계약을 희망하시는 경우 contact@o2o.kr 로 문의 주세요.',
);
};
return (
<section className="min-h-screen pt-32 pb-20 px-6 bg-gradient-to-b from-indigo-50/60 via-white to-white">
<div className="max-w-md mx-auto">
{/* 헤드라인 */}
<div className="text-center mb-8">
<Link
to="/"
className="font-serif text-3xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent"
>
INFINITH
</Link>
<h1 className="text-2xl font-serif font-bold text-primary-900 mt-6 mb-2">
</h1>
<p className="text-sm text-slate-500 break-keep leading-relaxed">
.
</p>
</div>
{/* 준비중 배너 */}
<div className="mb-6 rounded-2xl bg-amber-50/80 border border-amber-200 p-4 text-center">
<p className="text-xs font-semibold text-amber-800 break-keep leading-relaxed">
.
<br />
"문의하기" .
</p>
</div>
{/* 폼 카드 */}
<form
onSubmit={handleSubmit}
className="rounded-3xl bg-white border border-slate-200 shadow-sm p-8 space-y-5"
>
<div>
<label
htmlFor="login-email"
className="block text-xs font-semibold text-slate-600 mb-2"
>
</label>
<Input
id="login-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="clinic@example.com"
className="w-full px-4 py-3 h-auto text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 text-primary-900 placeholder:text-slate-400 transition-all"
autoComplete="email"
/>
</div>
<div>
<label
htmlFor="login-password"
className="block text-xs font-semibold text-slate-600 mb-2"
>
</label>
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-3 h-auto text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 text-primary-900 placeholder:text-slate-400 transition-all"
autoComplete="current-password"
/>
</div>
<Button
type="submit"
className="w-full px-6 py-3 h-auto text-sm font-bold text-white rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep shadow-sm hover:opacity-90 transition-all"
>
<ArrowRight className="w-4 h-4" />
</Button>
{notice && (
<div
role="status"
className="text-xs text-slate-600 text-center bg-slate-50 rounded-xl p-3 leading-relaxed break-keep"
>
{notice}
</div>
)}
</form>
{/* 하단 문의하기 */}
<div className="mt-6 text-center">
<p className="text-xs text-slate-500 mb-2"> ?</p>
<a
href={buildContactMailto('도입 문의 (LoginPage)')}
className="inline-flex items-center gap-1 text-sm font-semibold text-[#4F1DA1] hover:text-[#021341] transition-colors underline-offset-4 hover:underline"
>
</a>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const LoginPage = lazy(() => import('./pages/LoginPage'))
export const authRoutes: RouteObject[] = [
{ path: 'login', element: <LoginPage /> },
]

View File

@ -0,0 +1,216 @@
/**
* MultiChannelInput Hero / CTA URL .
*
* (PART III + 2026-04 ):
* - YouTube/Instagram/FB/// URL
* · .
* - textarea + "어떻게 입력해야 하는지"
* URL .
* - 7 "여기에 무엇을 넣어야 하는지" .
*
* :
* 1) 7 URL ( )
* 2) `classifyUrls()`
* 3) : , :
* 4) 1 Analyze
* 5) Submit onAnalyze(payload) ( navigation )
*
* DS :
* - Filled Icons Only (lucide )
* - DS Primary pill: rounded-full + gradient `from-[#4F1DA1] to-[#021341]`
* - variant='hero': ( Hero)
* - variant='cta': (CTA )
*/
import { useMemo, useState, type ReactElement } from 'react';
import { ArrowRight } from 'lucide-react';
import {
GlobeFilled,
YoutubeFilled,
InstagramFilled,
FacebookFilled,
DatabaseFilled,
FileTextFilled,
MessageFilled,
CheckFilled,
WarningFilled,
} from '@/shared/icons/FilledIcons';
import {
classifyUrls,
hasAnalyzableChannels,
pickPrimaryUrl,
type ClassifiedUrls,
} from '../lib/classifyUrls';
import { Button } from '@/shared/ui/button';
/** discover-channels Edge Function에 전달되는 수동 채널 URL 묶음. */
export interface ManualChannels {
youtube?: string[];
instagram?: string[];
facebook?: string[];
naverPlace?: string[];
naverBlog?: string[];
gangnamUnni?: string[];
}
export interface AnalyzePayload {
/** discover-channels `url` 필드 (필수) — 홈페이지 우선, 없으면 첫 SNS URL. */
primaryUrl: string;
/** 사용자가 제공한 채널별 URL — Edge Function이 Firecrawl discovery를 스킵. */
manualChannels: ManualChannels;
/** 사용자 원본 입력 (디버깅·재실행용) */
rawInput: string;
}
interface MultiChannelInputProps {
variant?: 'hero' | 'cta';
onAnalyze: (payload: AnalyzePayload) => void;
}
type ChannelKey = keyof Omit<ClassifiedUrls, 'unknown'>;
/** 채널별 메타 — 라벨/아이콘/색상/플레이스홀더 (뷰성형외과 실 URL을 데모 placeholder로). */
const CHANNEL_META: Array<{
key: ChannelKey;
label: string;
Icon: (props: { size?: number; className?: string }) => ReactElement;
color: string;
placeholder: string;
}> = [
{ key: 'homepage', label: '홈페이지', Icon: GlobeFilled, color: 'text-brand-purple', placeholder: 'viewclinic.com' },
{ key: 'youtube', label: 'YouTube', Icon: YoutubeFilled, color: 'text-[#FF0000]', placeholder: 'youtube.com/@ViewclinicKR' },
{ key: 'instagram', label: 'Instagram', Icon: InstagramFilled,color: 'text-[#E1306C]', placeholder: 'instagram.com/viewplastic' },
{ key: 'facebook', label: 'Facebook', Icon: FacebookFilled, color: 'text-[#1877F2]', placeholder: 'facebook.com/viewps1' },
{ key: 'naverPlace', label: '네이버 플레이스', Icon: DatabaseFilled, color: 'text-[#03C75A]', placeholder: 'place.naver.com/hospital/11709005' },
{ key: 'naverBlog', label: '네이버 블로그', Icon: FileTextFilled, color: 'text-[#03C75A]', placeholder: 'blog.naver.com/viewclinicps' },
{ key: 'gangnamUnni', label: '강남언니', Icon: MessageFilled, color: 'text-[#FF5C89]', placeholder: 'gangnamunni.com/hospitals/189' },
];
type EmptyClassified = Record<ChannelKey, string>;
const EMPTY_URLS: EmptyClassified = {
homepage: '', youtube: '', instagram: '', facebook: '',
naverPlace: '', naverBlog: '', gangnamUnni: '',
};
/**
* .
* : 'empty', : 'valid', SNS : 'wrong', : 'invalid'
*/
function validateField(value: string, expected: ChannelKey): 'empty' | 'valid' | 'wrong' | 'invalid' {
if (!value.trim()) return 'empty';
const c = classifyUrls(value);
if (c[expected].length > 0) return 'valid';
// 다른 채널로 분류됐으면 'wrong', unknown이면 'invalid'
for (const k of Object.keys(c) as Array<keyof ClassifiedUrls>) {
if (k !== 'unknown' && k !== expected && c[k].length > 0) return 'wrong';
}
return 'invalid';
}
export default function MultiChannelInput({ variant = 'hero', onAnalyze }: MultiChannelInputProps) {
const [urls, setUrls] = useState<EmptyClassified>(EMPTY_URLS);
// 통합 분류 결과 — 7개 필드 값을 join해 classifyUrls에 한 번에 통과시켜 manualChannels 구성.
const aggregated = useMemo(() => {
const joined = Object.values(urls).filter(Boolean).join('\n');
return classifyUrls(joined);
}, [urls]);
const canAnalyze = hasAnalyzableChannels(aggregated);
const primaryUrl = pickPrimaryUrl(aggregated);
const handleSubmit = () => {
if (!canAnalyze || !primaryUrl) return;
const payload: AnalyzePayload = {
primaryUrl,
manualChannels: {
youtube: aggregated.youtube.length ? aggregated.youtube : undefined,
instagram: aggregated.instagram.length ? aggregated.instagram : undefined,
facebook: aggregated.facebook.length ? aggregated.facebook : undefined,
naverPlace: aggregated.naverPlace.length ? aggregated.naverPlace : undefined,
naverBlog: aggregated.naverBlog.length ? aggregated.naverBlog : undefined,
gangnamUnni: aggregated.gangnamUnni.length ? aggregated.gangnamUnni : undefined,
},
rawInput: Object.entries(urls)
.filter(([, v]) => v.trim())
.map(([k, v]) => `${k}: ${v}`)
.join('\n'),
};
onAnalyze(payload);
};
// variant별 스타일 토큰
const isHero = variant === 'hero';
const inputClass = isHero
? 'w-full pl-11 pr-10 py-2.5 text-sm bg-white/80 backdrop-blur-sm border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent/40 shadow-sm text-primary-900 placeholder:text-slate-400 transition-all'
: 'w-full pl-11 pr-10 py-2.5 text-sm bg-white/5 backdrop-blur-sm border border-white/15 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/60 text-white placeholder:text-white/40 transition-all';
const labelClass = isHero ? 'text-slate-600' : 'text-white/70';
const helperClass = isHero ? 'text-slate-500' : 'text-white/60';
return (
<div className="w-full max-w-2xl mx-auto">
{/* 7개 채널별 입력 필드 — 한 줄에 하나, 좌측 아이콘 + 우측 검증 상태 */}
<div className="flex flex-col gap-2">
{CHANNEL_META.map(({ key, label, Icon, color, placeholder }) => {
const value = urls[key];
const status = validateField(value, key);
return (
<div key={key} className="relative">
{/* 좌측 채널 아이콘 — 입력 시 컬러 적용, 빈 칸일 땐 회색 */}
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none flex items-center gap-2">
<Icon size={16} className={value ? color : isHero ? 'text-slate-400' : 'text-white/40'} />
</div>
{/* 채널 라벨 — 좌측 상단 작은 라벨로 placeholder 위에 노출 */}
<input
type="url"
inputMode="url"
value={value}
onChange={(e) => setUrls((prev) => ({ ...prev, [key]: e.target.value }))}
placeholder={`${label} · ${placeholder}`}
aria-label={label}
className={inputClass}
spellCheck={false}
autoComplete="off"
/>
{/* 우측 검증 상태 아이콘 */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
{status === 'valid' && (
<CheckFilled size={16} className="text-emerald-500" />
)}
{(status === 'wrong' || status === 'invalid') && (
<WarningFilled size={16} className={isHero ? 'text-amber-500' : 'text-amber-300'} />
)}
</div>
</div>
);
})}
</div>
{/* 보조 안내 — 1개 이상 입력 시 어느 채널이 분석 대상인지 요약 */}
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${labelClass}`}>
7 URL 1 .
</p>
{/* 분석 시작 버튼 — DS Primary pill */}
<Button
onClick={handleSubmit}
disabled={!canAnalyze}
className={`w-full max-w-md mx-auto mt-4 px-10 py-4 h-auto text-lg font-medium rounded-full shadow-xl hover:shadow-2xl flex items-center justify-center gap-2 group bg-gradient-to-r transform-gpu will-change-transform transition-[transform,filter,box-shadow] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:hover:scale-[1.02] motion-safe:hover:brightness-110 active:scale-[0.98] active:duration-150 ${
isHero
? 'from-brand-purple to-brand-purple-deep text-white'
: 'from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky text-primary-900'
}`}
>
Analyze
<ArrowRight className="size-5 transform-gpu will-change-transform transition-transform duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-safe:group-hover:translate-x-1" />
</Button>
{/* 보조 안내 (하단) */}
<p className={`text-xs font-medium mt-3 text-center leading-relaxed break-keep ${helperClass}`}>
· · Online Presence .
</p>
</div>
);
}

View File

@ -0,0 +1,94 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useVerifyChannels } from '@/shared/api/generated/channels/channels';
import { mergeEnrichment, type EnrichmentData } from '@/features/report/lib/transformReport';
import type { MarketingReport } from '@/features/report/types/report';
// TODO(migration): 기존 enrichChannels Edge Function 은 백엔드의 /api/channels/verify 로 통합.
// 응답 스펙이 약간 다르므로 데이터 정합 시점에 추가 매핑 필요.
type EnrichmentStatus = 'idle' | 'loading' | 'success' | 'error';
interface UseEnrichmentResult {
status: EnrichmentStatus;
enrichedReport: MarketingReport | null;
/** 재시도 시도 횟수 */
retryCount: number;
/** enrichment를 재시도할 때 호출 (최대 2회 재시도) */
retry: () => void;
}
interface EnrichmentParams {
reportId: string | null;
clinicName: string;
instagramHandle?: string;
instagramHandles?: string[];
youtubeChannelId?: string;
facebookHandle?: string;
address?: string;
}
const MAX_RETRIES = 2;
/**
* Phase 1 enrichment .
* , verify (~27)
* . 2 .
*/
export function useEnrichment(
baseReport: MarketingReport | null,
params: EnrichmentParams | null,
): UseEnrichmentResult {
const [status, setStatus] = useState<EnrichmentStatus>('idle');
const [enrichedReport, setEnrichedReport] = useState<MarketingReport | null>(null);
const [retryCount, setRetryCount] = useState(0);
const hasTriggered = useRef(false);
const { mutateAsync: verifyChannelsAsync } = useVerifyChannels();
const doEnrich = useCallback(async () => {
if (!baseReport || !params?.reportId) return;
setStatus('loading');
try {
const result = await verifyChannelsAsync({
// TODO(migration): backend ChannelVerifyRequest 스키마와 정확한 필드 매핑 확인 필요
data: {
instagram: params.instagramHandle ? { handle: params.instagramHandle } : undefined,
youtube: params.youtubeChannelId ? { channel_id: params.youtubeChannelId } : undefined,
facebook: params.facebookHandle ? { handle: params.facebookHandle } : undefined,
} as unknown as Parameters<typeof verifyChannelsAsync>[0]['data'],
});
if (result.status === 200 && result.data) {
const merged = mergeEnrichment(baseReport, result.data as unknown as EnrichmentData);
setEnrichedReport(merged);
setStatus('success');
} else {
setStatus('error');
}
} catch {
setStatus('error');
}
}, [baseReport, params, verifyChannelsAsync]);
// 초기 트리거
useEffect(() => {
if (!baseReport || !params?.reportId || hasTriggered.current) return;
hasTriggered.current = true;
doEnrich();
}, [baseReport, params, doEnrich]);
// 수동 재시도
const retry = useCallback(() => {
if (retryCount >= MAX_RETRIES) return;
setRetryCount(prev => prev + 1);
doEnrich();
}, [retryCount, doEnrich]);
return {
status,
enrichedReport,
retryCount,
retry,
};
}

View File

@ -0,0 +1,258 @@
/**
* classifyUrls MultiChannelInput URL .
*
* textarea URL 7 (holder) .
* - homepage · youtube · instagram · facebook · naverPlace · naverBlog · gangnamUnni · unknown
*
* :
* 1) regex (AI ) extractSocialLinks .
* ** ** . SNS URL .
*
* 2) : naverPlace / gangnamUnni ( ) YouTube / Instagram / Facebook / naverBlog
* `new URL()` URL ** SNS ** homepage.
* SNS (: instagram.com/p/XYZ, youtube.com/watch) `unknown`.
* "homepage" , URL
* . .
*
* 3) ** URL ** backend `discover-channels` `manualChannels`
* URL handle , handle .
* ( discover-channels extractHandleFromUrl )
*
* 4) // , URL( + trailing slash ) dedup.
*/
export interface ClassifiedUrls {
homepage: string[];
youtube: string[];
instagram: string[];
facebook: string[];
naverPlace: string[];
naverBlog: string[];
gangnamUnni: string[];
/** 파싱 실패 or SNS 도메인이지만 프로필이 아닌 URL (포스트/비디오 등) */
unknown: string[];
}
/** SNS 호스트네임 집합 — 이들 도메인인데 프로필 패턴에 매치되지 않으면 `unknown`으로 강제. */
const SNS_HOSTNAMES = new Set([
'instagram.com',
'www.instagram.com',
'm.instagram.com',
'youtube.com',
'www.youtube.com',
'm.youtube.com',
'youtu.be',
'facebook.com',
'www.facebook.com',
'm.facebook.com',
'fb.com',
'blog.naver.com',
'm.blog.naver.com',
'place.naver.com',
'm.place.naver.com',
'map.naver.com',
'gangnamunni.com',
'm.gangnamunni.com',
'www.gangnamunni.com',
]);
/** Instagram 프로필이 아닌 경로 (포스트/릴/스토리 등) */
const IG_SKIP = new Set([
'p', 'reel', 'reels', 'stories', 'explore', 'accounts',
'about', 'developer', 'legal', 'privacy', 'terms',
]);
/** Facebook 페이지가 아닌 경로 */
const FB_SKIP = new Set([
'sharer', 'share', 'login', 'help', 'pages', 'events', 'groups',
'marketplace', 'watch', 'gaming', 'privacy', 'policies', 'tr',
'dialog', 'plugins', 'photo', 'video', 'reel',
]);
/** YouTube 프로필 패턴: `@handle`, `channel/UC...`, `c/custom`, `user/...` */
const YT_PROFILE_RE = /youtube\.com\/(?:@[a-zA-Z0-9._-]+|channel\/UC[a-zA-Z0-9_-]+|c\/[a-zA-Z0-9._-]+|user\/[a-zA-Z0-9._-]+)/i;
/** Naver Place: m.place.naver.com/hospital/{id}, place.naver.com/hospital/{id}, map.naver.com/p/entry/place/{id} */
const NAVER_PLACE_RE = /(?:m\.)?place\.naver\.com\/[a-z]+\/\d+|map\.naver\.com\/p\/entry\/place\/\d+/i;
/** Naver Blog: blog.naver.com/{id} (또는 m.blog.naver.com) */
const NAVER_BLOG_RE = /(?:m\.)?blog\.naver\.com\/[a-zA-Z0-9_-]+/i;
/** 강남언니: gangnamunni.com/hospitals/{id-or-slug} */
const GANGNAMUNNI_RE = /(?:m\.|www\.)?gangnamunni\.com\/hospitals?\/[a-zA-Z0-9_-]+/i;
/** Instagram 프로필: instagram.com/{handle} — 포스트/릴 등은 아래 IG_SKIP로 걸러냄 */
const IG_RE = /(?:www\.|m\.)?instagram\.com\/([a-zA-Z0-9._]+)\/?/i;
/** Facebook 페이지: facebook.com/{page} */
const FB_RE = /(?:www\.|m\.)?facebook\.com\/([a-zA-Z0-9._-]+)\/?/i;
/**
* URL .
* - , trailing slash , scheme
* - URL trim.
*/
function normalizeForDedup(url: string): string {
try {
const u = new URL(url);
const path = u.pathname.replace(/\/+$/, '');
return `${u.protocol}//${u.hostname.toLowerCase()}${path}${u.search}`.toLowerCase();
} catch {
return url.trim().toLowerCase();
}
}
/**
* URL .
* : [bucketKey, originalUrl] null ( ).
*/
function classifySingle(
rawToken: string,
): { bucket: keyof ClassifiedUrls; value: string } | null {
const token = rawToken.trim();
if (!token || token.length < 4) return null;
// URL 파싱 시도 — 실패하면 unknown
let parsed: URL;
try {
// scheme 없는 경우 https:// 추가 (예: "instagram.com/foo")
const withScheme = /^https?:\/\//i.test(token) ? token : `https://${token}`;
parsed = new URL(withScheme);
} catch {
return { bucket: 'unknown', value: token };
}
const hostname = parsed.hostname.toLowerCase();
const fullUrl = parsed.toString();
// 1) Naver Place (고유 도메인)
if (NAVER_PLACE_RE.test(fullUrl)) {
return { bucket: 'naverPlace', value: fullUrl };
}
// 2) 강남언니 (고유 도메인)
if (GANGNAMUNNI_RE.test(fullUrl)) {
return { bucket: 'gangnamUnni', value: fullUrl };
}
// 3) Naver Blog
if (NAVER_BLOG_RE.test(fullUrl)) {
return { bucket: 'naverBlog', value: fullUrl };
}
// 4) YouTube — 프로필 패턴만 매치
if (hostname.endsWith('youtube.com') || hostname === 'youtu.be') {
if (YT_PROFILE_RE.test(fullUrl)) {
return { bucket: 'youtube', value: fullUrl };
}
// youtube.com 인데 프로필 아님 (watch URL 등) → unknown
return { bucket: 'unknown', value: fullUrl };
}
// 5) Instagram
if (hostname.endsWith('instagram.com')) {
const m = fullUrl.match(IG_RE);
const firstSeg = m?.[1];
if (firstSeg && !IG_SKIP.has(firstSeg.toLowerCase())) {
return { bucket: 'instagram', value: fullUrl };
}
return { bucket: 'unknown', value: fullUrl };
}
// 6) Facebook
if (hostname.endsWith('facebook.com') || hostname.endsWith('fb.com')) {
const m = fullUrl.match(FB_RE);
const firstSeg = m?.[1];
if (firstSeg && !FB_SKIP.has(firstSeg.toLowerCase())) {
return { bucket: 'facebook', value: fullUrl };
}
return { bucket: 'unknown', value: fullUrl };
}
// 7) 남은 케이스: SNS 도메인이면 unknown (프로필 패턴 미매치), 아니면 homepage
if (SNS_HOSTNAMES.has(hostname)) {
return { bucket: 'unknown', value: fullUrl };
}
return { bucket: 'homepage', value: fullUrl };
}
/**
* textarea 7 .
* // URL .
*/
export function classifyUrls(input: string): ClassifiedUrls {
const result: ClassifiedUrls = {
homepage: [],
youtube: [],
instagram: [],
facebook: [],
naverPlace: [],
naverBlog: [],
gangnamUnni: [],
unknown: [],
};
if (!input || typeof input !== 'string') return result;
// 공백/쉼표/줄바꿈으로 분리
const tokens = input.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean);
// 버킷별 중복 체크 — 같은 URL이 textarea에 두 번 나와도 한 번만
const seen: Record<keyof ClassifiedUrls, Set<string>> = {
homepage: new Set(),
youtube: new Set(),
instagram: new Set(),
facebook: new Set(),
naverPlace: new Set(),
naverBlog: new Set(),
gangnamUnni: new Set(),
unknown: new Set(),
};
for (const token of tokens) {
const classified = classifySingle(token);
if (!classified) continue;
const key = normalizeForDedup(classified.value);
if (seen[classified.bucket].has(key)) continue;
seen[classified.bucket].add(key);
result[classified.bucket].push(classified.value);
}
return result;
}
/**
* .
* homepage·SNS 1 true. unknown .
*/
export function hasAnalyzableChannels(classified: ClassifiedUrls): boolean {
return (
classified.homepage.length > 0 ||
classified.youtube.length > 0 ||
classified.instagram.length > 0 ||
classified.facebook.length > 0 ||
classified.naverPlace.length > 0 ||
classified.naverBlog.length > 0 ||
classified.gangnamUnni.length > 0
);
}
/**
* "primary URL" .
* , SNS URL .
* startAnalysis `url` () .
*/
export function pickPrimaryUrl(classified: ClassifiedUrls): string | null {
if (classified.homepage[0]) return classified.homepage[0];
// 홈페이지 없으면 SNS 중 아무거나 대표값으로
const fallback =
classified.naverPlace[0] ||
classified.instagram[0] ||
classified.youtube[0] ||
classified.facebook[0] ||
classified.naverBlog[0] ||
classified.gangnamUnni[0];
return fallback || null;
}

View File

@ -0,0 +1,32 @@
/**
* Instagram username .
* normalizeHandles .
*/
export function normalizeInstagramHandle(
raw: string | null | undefined,
): string | null {
if (!raw || typeof raw !== 'string') return null;
let handle = raw.trim();
if (!handle) return null;
if (handle.includes('instagram.com')) {
try {
const urlStr = handle.startsWith('http') ? handle : `https://${handle}`;
const url = new URL(urlStr);
const segments = url.pathname.split('/').filter(Boolean);
handle = segments[0] || '';
} catch {
const match = handle.match(/instagram\.com\/([^/?#]+)/);
handle = match?.[1] || '';
}
}
if (handle.startsWith('@')) {
handle = handle.slice(1);
}
handle = handle.replace(/\/+$/, '');
return handle || null;
}

View File

@ -0,0 +1,376 @@
import { useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '@/shared/icons/FilledIcons';
import type { ComponentType } from 'react';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
interface ChannelDef {
id: string;
name: string;
description: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
borderColor: string;
fields: { key: string; label: string; placeholder: string; type?: string }[];
}
const CHANNELS: ChannelDef[] = [
{
id: 'youtube',
name: 'YouTube',
description: '채널 연동으로 영상 자동 업로드, 성과 분석',
icon: YoutubeFilled,
brandColor: '#FF0000',
bgColor: '#FFF0F0',
borderColor: '#F5D5DC',
fields: [
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
],
},
{
id: 'instagram_kr',
name: 'Instagram KR',
description: '한국 계정 — Reels, Feed, Stories 자동 게시',
icon: InstagramFilled,
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewclinic_kr' },
],
},
{
id: 'instagram_en',
name: 'Instagram EN',
description: '글로벌 계정 — 해외 환자 대상 콘텐츠',
icon: InstagramFilled,
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewplasticsurgery' },
],
},
{
id: 'facebook_kr',
name: 'Facebook KR',
description: '한국 페이지 — 광고 리타겟, 콘텐츠 배포',
icon: FacebookFilled,
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPage' },
],
},
{
id: 'facebook_en',
name: 'Facebook EN',
description: '글로벌 페이지 — 해외 환자 유입',
icon: FacebookFilled,
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPageEN' },
],
},
{
id: 'naver_blog',
name: 'Naver Blog',
description: 'SEO 블로그 포스트 자동 게시, 키워드 최적화',
icon: GlobeFilled,
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'blogUrl', label: '블로그 URL', placeholder: 'https://blog.naver.com/yourblog' },
{ key: 'apiKey', label: 'API Key', placeholder: 'Naver Open API Key', type: 'password' },
],
},
{
id: 'naver_place',
name: 'Naver Place',
description: '플레이스 정보 동기화, 리뷰 모니터링',
icon: GlobeFilled,
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
],
},
{
id: 'tiktok',
name: 'TikTok',
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
icon: TiktokFilled,
brandColor: '#000000',
bgColor: '#F5F5F5',
borderColor: '#E0E0E0',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
],
},
{
id: 'gangnamunni',
name: '강남언니',
description: '리뷰 모니터링, 평점 추적, 시술 정보 동기화',
icon: GlobeFilled,
brandColor: '#6B2D8B',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'hospitalUrl', label: '병원 페이지 URL', placeholder: 'https://gangnamunni.com/hospitals/...' },
],
},
{
id: 'website',
name: 'Website',
description: '홈페이지 SEO 모니터링, 트래킹 픽셀 관리',
icon: GlobeFilled,
brandColor: '#6C5CE7',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'url', label: '웹사이트 URL', placeholder: 'https://www.yourclinic.com' },
{ key: 'gaId', label: 'Google Analytics ID', placeholder: 'G-XXXXXXXXXX' },
],
},
];
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
interface ChannelState {
status: ConnectionStatus;
values: Record<string, string>;
}
export default function ChannelConnectPage() {
const navigate = useNavigate();
const [channels, setChannels] = useState<Record<string, ChannelState>>(() => {
const init: Record<string, ChannelState> = {};
for (const ch of CHANNELS) {
init[ch.id] = { status: 'disconnected', values: {} };
}
return init;
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleFieldChange = useCallback((channelId: string, fieldKey: string, value: string) => {
setChannels(prev => ({
...prev,
[channelId]: {
...prev[channelId],
values: { ...prev[channelId].values, [fieldKey]: value },
},
}));
}, []);
const handleConnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connecting' },
}));
// 연결 시뮬레이션
setTimeout(() => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connected' },
}));
}, 2000);
}, []);
const handleDisconnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { status: 'disconnected', values: {} },
}));
}, []);
const connectedCount = (Object.values(channels) as ChannelState[]).filter(c => c.status === 'connected').length;
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky py-16 px-6">
<div className="max-w-5xl mx-auto">
<p className="text-xs font-semibold text-brand-purple-vivid tracking-widest uppercase mb-3">Channel Integration</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-brand-purple-deep mb-3">
</h1>
<p className="text-brand-purple-deep/60 max-w-xl">
.
</p>
{/* Connection Summary + Distribute Button */}
<div className="flex items-center gap-4 mt-8">
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/70 backdrop-blur-sm border border-white/40">
<div className={`w-3 h-3 rounded-full ${connectedCount > 0 ? 'bg-brand-purple-vivid' : 'bg-slate-300'}`} />
<span className="text-sm font-medium text-brand-purple-deep">
{connectedCount} / {CHANNELS.length}
</span>
</div>
{connectedCount > 0 && (
<Button
onClick={() => navigate('/distribute')}
className="flex items-center gap-2 px-5 py-2 h-auto rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
)}
</div>
</div>
</div>
{/* Channel Grid */}
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CHANNELS.map(ch => {
const state = channels[ch.id];
const isExpanded = expandedId === ch.id;
const Icon = ch.icon;
const allFieldsFilled = ch.fields.every(f => (state.values[f.key] ?? '').trim().length > 0);
return (
<motion.div
key={ch.id}
layout
className={`rounded-2xl border-2 overflow-hidden transition-all ${
state.status === 'connected'
? 'border-brand-tint-lavender bg-brand-tint-purple/20'
: isExpanded
? 'border-brand-purple-vivid bg-white shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-brand-tint-lavender'
}`}
>
{/* Card Header */}
<Button
variant="ghost"
onClick={() => setExpandedId(isExpanded ? null : ch.id)}
className="w-full h-auto flex items-center justify-start gap-4 p-5 text-left rounded-none whitespace-normal hover:bg-transparent"
>
<div
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<Icon size={22} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-brand-navy">{ch.name}</h3>
{state.status === 'connected' && (
<span className="px-2 py-1 rounded-full bg-brand-tint-purple text-brand-purple-muted text-xs font-semibold border border-brand-tint-lavender">
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-1 truncate">{ch.description}</p>
</div>
<svg
width="20" height="20" viewBox="0 0 20 20" fill="none"
className={`shrink-0 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
>
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
{/* Expanded Content */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
{state.status === 'connected' ? (
<div>
<div className="flex items-center gap-2 mb-4 mt-3">
<div className="w-2 h-2 rounded-full bg-brand-purple-vivid" />
<span className="text-sm text-brand-purple-muted font-medium"> : </span>
</div>
{ch.fields.map(f => (
<div key={f.key} className="mb-2">
<span className="text-xs text-slate-400">{f.label}</span>
<p className="text-sm text-slate-700 font-medium">
{f.type === 'password' ? '••••••••' : state.values[f.key]}
</p>
</div>
))}
<Button
variant="outline"
onClick={() => handleDisconnect(ch.id)}
className="mt-4 w-full h-auto py-3 rounded-full bg-white border-brand-rose-soft text-brand-rose text-sm font-medium hover:bg-brand-rose-bg"
>
</Button>
</div>
) : (
<div className="mt-3">
{ch.fields.map(f => (
<div key={f.key} className="mb-3">
<label className="text-xs font-medium text-slate-600 mb-1 block">{f.label}</label>
<Input
type={f.type ?? 'text'}
value={state.values[f.key] ?? ''}
onChange={e => handleFieldChange(ch.id, f.key, e.target.value)}
placeholder={f.placeholder}
className="w-full px-4 py-3 h-auto rounded-xl border-slate-200 text-sm text-slate-700 placeholder:text-slate-300 focus-visible:border-brand-purple-vivid focus-visible:ring-brand-purple-vivid/20"
/>
</div>
))}
<div className="flex gap-2 mt-4">
<Button
onClick={() => handleConnect(ch.id)}
disabled={!allFieldsFilled || state.status === 'connecting'}
className="flex-1 h-auto flex items-center justify-center gap-2 py-3 rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{state.status === 'connecting' ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</>
) : (
'연결하기'
)}
</Button>
<Button
variant="outline"
className="h-auto px-4 py-3 rounded-full bg-white border-slate-200 text-slate-500 text-sm font-medium hover:bg-slate-50"
>
OAuth
</Button>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const ChannelConnectPage = lazy(() => import('./pages/ChannelConnectPage'))
export const channelsRoutes: RouteObject[] = [
{ path: 'channels', element: <ChannelConnectPage /> },
]

View File

@ -0,0 +1,85 @@
/**
* ClinicProfilePage fallback/seed .
* API ,
* / mutate .
*
* - CLINIC: ( mutate)
* - DOCTORS / RATINGS / PROCEDURES: (length=0 + push )
*
* TODO: mutate SSR/
* React state .
*/
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
} from '@/shared/icons/FilledIcons';
import { MessageSquare } from 'lucide-react';
export const CLINIC: Record<string, any> = {
name: '뷰성형외과의원',
nameEn: 'VIEW Plastic Surgery',
logo: '/assets/clients/view-clinic/logo-circle.png',
brandColor: '#7B2D8E',
established: 2005,
location: '서울 강남구 봉은사로 107 뷰성형외과 빌딩',
nearestStation: '9호선 신논현역 3번 출구 도보 1분',
phone: '02-539-1177',
hours: [
{ day: '월~목', time: '10:00 19:00' },
{ day: '금요일', time: '10:00 21:00' },
{ day: '토요일', time: '10:00 17:00' },
{ day: '일/공휴일', time: '휴진' },
],
specialties: ['가슴성형', '안면윤곽', '양악', '눈성형', '코성형', '지방흡입', '리프팅', '피부시술', '필러/보톡스'],
certifications: [
'수술실 CCTV 운영',
'전담 마취과 전문의 상주',
'입원실 완비',
'의료진 실명 공개',
'응급 대응 체계',
'분야별 공동 진료',
'전용 휴식 공간',
'시술 후 관리',
'야간 진료',
'여성 의사 진료',
],
mediaAppearances: ['렛미인 TV 출연', '보건복지부장관 표창 수상', '안면윤곽 대상 수상', '모티바 사용량 1위'],
websites: [
{ label: '공식 홈페이지', url: 'viewclinic.com', primary: true },
{ label: '영문 사이트', url: 'viewplasticsurgery.com', primary: false },
],
socialChannels: [
{ platform: 'YouTube', handle: '@ViewclinicKR', url: 'youtube.com/@ViewclinicKR', followers: '103K 구독자', videos: '1,064개 영상', icon: YoutubeFilled, color: '#FF0000' },
{ platform: 'Instagram KR', handle: '@viewplastic', url: 'instagram.com/viewplastic', followers: '14K 팔로워', videos: '1,409 게시물', icon: InstagramFilled, color: '#E1306C' },
{ platform: 'Instagram EN', handle: '@view_plastic_surgery', url: 'instagram.com/view_plastic_surgery', followers: '68.8K 팔로워', videos: '2,524 게시물', icon: InstagramFilled, color: '#E1306C' },
{ platform: 'Facebook', handle: 'View Plastic Surgery', url: 'facebook.com/viewclinic', followers: '88K 팔로워', videos: '', icon: FacebookFilled, color: '#1877F2' },
{ platform: '카카오톡', handle: '뷰성형외과의원', url: 'pf.kakao.com/_xbtVxjl', followers: '상담 채널', videos: '', icon: MessageSquare as any, color: '#FEE500' },
],
};
export const DOCTORS: Record<string, any>[] = [
{ name: '최순우', title: '대표원장', specialty: '가슴성형', credentials: '서울대 출신, 의학박사', rating: 9.4, reviews: 1812, featured: true },
{ name: '정재현', title: '원장', specialty: '가슴성형', credentials: '성형외과 전문의', rating: 9.6, reviews: 3177, featured: false },
{ name: '김정민', title: '원장', specialty: '리프팅 · 눈성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 878, featured: false },
{ name: '윤창운', title: '원장', specialty: '안면윤곽 · 양악', credentials: '성형외과 전문의', rating: 9.6, reviews: 764, featured: false },
{ name: '조진우', title: '원장', specialty: '리프팅 · 지방 · 눈코', credentials: '성형외과 전문의', rating: 9.6, reviews: 1624, featured: false },
{ name: '김도형', title: '원장', specialty: '눈성형 · 코성형', credentials: '성형외과 전문의', rating: 9.7, reviews: 191, featured: false },
];
export const RATINGS: Record<string, any>[] = [
{ platform: '강남언니', rating: '9.5', scale: '/10', reviews: '18,961건', color: '#FF6B8A', pct: 95 },
{ platform: '네이버 플레이스', rating: '4.6', scale: '/5', reviews: '324건', color: '#03C75A', pct: 92 },
{ platform: 'Google Maps', rating: '4.3', scale: '/5', reviews: '187건', color: '#4285F4', pct: 86 },
];
export const PROCEDURES = [
{ name: '가슴성형 (보형물)', price: '₩2,650,000~', category: '가슴' },
{ name: '모티바 가슴성형', price: '₩11,550,000~', category: '가슴' },
{ name: '눈성형 (매몰/절개)', price: '₩440,000~', category: '눈' },
{ name: '코성형', price: '₩990,000~', category: '코' },
{ name: '안면윤곽', price: '₩3,289,000~', category: '윤곽' },
{ name: '지방흡입', price: '₩1,100,000~', category: '바디' },
{ name: '실리프팅', price: '₩1,958,000~', category: '리프팅' },
{ name: '필러', price: '₩220,000~', category: '피부' },
];

View File

@ -0,0 +1,448 @@
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { motion } from 'motion/react';
import {
InstagramFilled,
FacebookFilled,
GlobeFilled,
YoutubeFilled,
} from '@/shared/icons/FilledIcons';
import {
MapPin,
Phone,
Clock,
Award,
ShieldCheck,
Star,
ExternalLink,
Users,
Video,
ChevronRight,
BadgeCheck,
Building2,
GraduationCap,
Stethoscope,
Heart,
TrendingUp,
} from 'lucide-react';
import { useGetReport } from '@/shared/api/generated/reports/reports';
import { CLINIC, DOCTORS, RATINGS, PROCEDURES } from '../data/mockClinicProfile';
export default function ClinicProfilePage() {
const { id } = useParams<{ id: string }>();
const { data: reportRes, isLoading, error } = useGetReport(id ?? '', {
query: { enabled: !!id },
});
// 가능한 경우 DB 데이터로 CLINIC 데이터 덮어쓰기
useEffect(() => {
if (!reportRes || reportRes.status !== 200) return;
const row = reportRes.data as unknown as Record<string, unknown>;
const report = (row.report as Record<string, unknown>) || row;
const clinicInfo = (report.clinicInfo as Record<string, unknown>)
|| (row.clinic as Record<string, unknown>)
|| undefined;
const scrapeData = row.scrape_data as Record<string, unknown> | undefined;
void scrapeData;
if (clinicInfo?.name) CLINIC.name = clinicInfo.name as string;
if (clinicInfo?.address) CLINIC.location = clinicInfo.address as string;
if (clinicInfo?.phone) CLINIC.phone = clinicInfo.phone as string;
if (clinicInfo?.services) CLINIC.specialties = clinicInfo.services as string[];
if (clinicInfo?.doctors) {
const docs = clinicInfo.doctors as { name: string; specialty: string }[];
DOCTORS.length = 0;
docs.forEach((d) => {
DOCTORS.push({ name: d.name, title: '원장', specialty: d.specialty, credentials: '성형외과 전문의', rating: 0, reviews: 0, featured: false });
});
if (DOCTORS.length > 0) DOCTORS[0].featured = true;
}
const clinicNameStr = (row.clinic_name as string) || '';
CLINIC.nameEn = clinicNameStr.includes('의원') ? '' : clinicNameStr;
// 웹사이트 업데이트
const urlStr = (row.url as string) || '';
const domain = (() => { try { return new URL(urlStr).hostname; } catch { return urlStr; } })();
CLINIC.websites = [{ label: '공식 홈페이지', url: domain, primary: true }];
// socialHandles에서 소셜 정보 업데이트
const handles = report.socialHandles as Record<string, string | null> | undefined;
if (handles) {
CLINIC.socialChannels = [];
if (handles.instagram) CLINIC.socialChannels.push({ platform: 'Instagram', handle: `@${handles.instagram}`, url: `instagram.com/${handles.instagram}`, followers: '-', videos: '', icon: InstagramFilled, color: '#E1306C' });
if (handles.youtube) CLINIC.socialChannels.push({ platform: 'YouTube', handle: `@${handles.youtube}`, url: `youtube.com/${handles.youtube}`, followers: '-', videos: '', icon: YoutubeFilled, color: '#FF0000' });
if (handles.facebook) CLINIC.socialChannels.push({ platform: 'Facebook', handle: handles.facebook, url: `facebook.com/${handles.facebook}`, followers: '-', videos: '', icon: FacebookFilled, color: '#1877F2' });
}
// 채널 분석에서 평점 업데이트
const chAnalysis = report.channelAnalysis as Record<string, Record<string, unknown>> | undefined;
if (chAnalysis) {
RATINGS.length = 0;
if (chAnalysis.gangnamUnni?.rating) {
const guRating = chAnalysis.gangnamUnni.rating as number;
const guScale = guRating > 5 ? '/10' : '/5';
const guPct = guRating > 5 ? (guRating / 10) * 100 : (guRating / 5) * 100;
RATINGS.push({ platform: '강남언니', rating: `${guRating}`, scale: guScale, reviews: `${chAnalysis.gangnamUnni.reviews ?? '-'}`, color: '#FF6B8A', pct: guPct });
}
if (chAnalysis.naverPlace?.rating) RATINGS.push({ platform: '네이버 플레이스', rating: `${chAnalysis.naverPlace.rating}`, scale: '/5', reviews: `${chAnalysis.naverPlace.reviews ?? '-'}`, color: '#03C75A', pct: ((chAnalysis.naverPlace.rating as number) / 5) * 100 });
}
}, [reportRes]);
if (id && isLoading) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">{error instanceof Error ? error.message : '리포트를 불러올 수 없습니다.'}</p>
</div>
</div>
);
}
return (
<div className="pt-20 min-h-screen bg-slate-50">
{/* ── Hero / Clinic Header ── */}
<div className="bg-[#0A1128] pt-10 pb-16 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#7B2D8E]/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-5xl mx-auto relative">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-xs text-purple-300/60 mb-6">
<span> </span>
<ChevronRight size={12} />
<span></span>
<ChevronRight size={12} />
<span className="text-purple-200"></span>
</div>
<div className="flex flex-col md:flex-row items-start gap-6">
{/* Logo */}
<div className="w-20 h-20 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/20 flex items-center justify-center flex-shrink-0 overflow-hidden">
<div className="w-16 h-16 rounded-xl flex items-center justify-center" style={{ backgroundColor: CLINIC.brandColor }}>
<span className="text-white font-serif font-bold text-lg">VIEW</span>
</div>
</div>
{/* Info */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h1 className="font-serif text-2xl md:text-3xl font-bold text-white">{CLINIC.name}</h1>
<BadgeCheck size={22} className="text-[#9B8AD4]" />
</div>
<p className="text-purple-200/60 text-sm mb-4">{CLINIC.nameEn} · {CLINIC.established} · {new Date().getFullYear() - CLINIC.established}</p>
{/* Quick Stats */}
<div className="flex flex-wrap gap-3">
{[
{ icon: Star, label: '강남언니 9.5점', sub: '18,961 리뷰' },
{ icon: Users, label: `의료진 ${DOCTORS.length}`, sub: '성형외과 전문의' },
{ icon: Video, label: 'YouTube 103K', sub: '1,064 영상' },
].map((stat) => {
const Icon = stat.icon;
return (
<div key={stat.label} className="flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 border border-white/10">
<Icon size={16} className="text-purple-300" />
<div>
<p className="text-sm font-medium text-white">{stat.label}</p>
<p className="text-xs text-purple-300/60">{stat.sub}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 -mt-6">
<div className="space-y-6">
{/* ── 기본 정보 카드 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Building2 size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<div className="flex items-start gap-3">
<MapPin size={16} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-sm font-medium text-primary-900">{CLINIC.location}</p>
<p className="text-xs text-slate-400">{CLINIC.nearestStation}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Phone size={16} className="text-slate-400 flex-shrink-0" />
<p className="text-sm font-medium text-primary-900">{CLINIC.phone}</p>
</div>
<div className="flex items-start gap-3">
<GlobeFilled size={16} className="text-slate-400 flex-shrink-0" />
<div className="flex flex-wrap gap-2">
{CLINIC.websites.map(w => (
<span key={w.url} className={`text-sm ${w.primary ? 'text-[#6C5CE7] font-medium' : 'text-slate-400'}`}>
{w.url}
</span>
))}
</div>
</div>
</div>
<div>
<div className="flex items-start gap-3">
<Clock size={16} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
{CLINIC.hours.map(h => (
<div key={h.day} className="flex gap-3 text-sm">
<span className="text-slate-400 w-16">{h.day}</span>
<span className={`font-medium ${h.time === '휴진' ? 'text-[#D4889A]' : 'text-primary-900'}`}>{h.time}</span>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
{/* ── 플랫폼별 통합 평점 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Star size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{RATINGS.map((r, i) => (
<motion.div
key={r.platform}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="rounded-xl border border-slate-100 p-4"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-slate-500">{r.platform}</span>
<span className="text-xs text-slate-400">{r.reviews}</span>
</div>
<div className="flex items-baseline gap-1 mb-3">
<span className="text-3xl font-bold text-primary-900">{r.rating}</span>
<span className="text-sm text-slate-400">{r.scale}</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${r.pct}%` }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: i * 0.1 + 0.2 }}
className="h-full rounded-full"
style={{ backgroundColor: r.color }}
/>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* ── 의료진 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Stethoscope size={18} className="text-[#6C5CE7]" />
({DOCTORS.length})
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{DOCTORS.map((doc, i) => (
<motion.div
key={doc.name}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.06 }}
className={`rounded-xl border p-4 ${doc.featured ? 'border-[#D5CDF5] bg-[#F3F0FF]/30' : 'border-slate-100'}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-slate-100 flex items-center justify-center flex-shrink-0">
<GraduationCap size={18} className="text-slate-400" />
</div>
<div>
<div className="flex items-center gap-1.5">
<p className="font-semibold text-primary-900">{doc.name}</p>
<span className="text-xs text-slate-400">{doc.title}</span>
{doc.featured && <span className="text-xs px-1.5 py-0.5 rounded bg-[#6C5CE7] text-white font-medium"></span>}
</div>
<p className="text-xs text-slate-400">{doc.credentials}</p>
</div>
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">{doc.specialty}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary-900">{doc.rating}</span>
<span className="text-xs text-slate-400">/ 10</span>
<span className="text-xs text-slate-300">·</span>
<span className="text-xs text-slate-400">{doc.reviews.toLocaleString()}</span>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
{/* ── 시술 및 가격 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<Heart size={18} className="text-[#6C5CE7]" />
</h2>
<p className="text-xs text-slate-400 mb-4"> · </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{PROCEDURES.map((proc, i) => (
<motion.div
key={proc.name}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.04 }}
className="flex items-center justify-between px-4 py-3 rounded-xl hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-xs px-2 py-0.5 rounded-full bg-[#F3F0FF] text-[#4A3A7C] font-medium">{proc.category}</span>
<span className="text-sm text-primary-900">{proc.name}</span>
</div>
<span className="text-sm font-bold text-primary-900">{proc.price}</span>
</motion.div>
))}
</div>
</motion.div>
{/* ── 인증 & 수상 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<ShieldCheck size={18} className="text-[#6C5CE7]" />
</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 mb-6">
{CLINIC.certifications.map((cert, i) => (
<motion.div
key={cert}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: i * 0.03 }}
className="flex items-center gap-2 px-3 py-2.5 rounded-xl bg-[#F3F0FF]/50 border border-[#D5CDF5]/50"
>
<BadgeCheck size={14} className="text-[#9B8AD4] flex-shrink-0" />
<span className="text-xs text-[#4A3A7C] font-medium">{cert}</span>
</motion.div>
))}
</div>
<div className="flex flex-wrap gap-2">
{CLINIC.mediaAppearances.map(m => (
<span key={m} className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[#FFF6ED] border border-[#F5E0C5] text-xs text-[#7C5C3A] font-medium">
<Award size={12} />
{m}
</span>
))}
</div>
</motion.div>
{/* ── 온라인 채널 ── */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<h2 className="font-serif text-lg font-bold text-primary-900 mb-4 flex items-center gap-2">
<TrendingUp size={18} className="text-[#6C5CE7]" />
</h2>
<div className="space-y-3">
{CLINIC.socialChannels.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.platform}
initial={{ opacity: 0, x: -10 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.06 }}
className="flex items-center gap-4 px-4 py-3 rounded-xl border border-slate-100 hover:border-slate-200 transition-colors"
>
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0" style={{ backgroundColor: `${ch.color}12` }}>
<Icon size={20} style={{ color: ch.color }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-900 text-sm">{ch.platform}</p>
<span className="text-xs text-slate-400">{ch.handle}</span>
</div>
<p className="text-xs text-slate-400">{ch.url}</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-sm font-semibold text-primary-900">{ch.followers}</p>
{ch.videos && <p className="text-xs text-slate-400">{ch.videos}</p>}
</div>
<ExternalLink size={14} className="text-slate-300 flex-shrink-0" />
</motion.div>
);
})}
</div>
</motion.div>
{/* ── 데이터 출처 고지 ── */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="rounded-2xl bg-slate-100/50 border border-slate-200/50 p-5 mb-10"
>
<p className="text-xs text-slate-400 leading-relaxed">
<span className="font-semibold text-slate-500"> :</span>{' '}
.
, , Google Maps ,
. .
: 2026-03-30 · : contact@infinith.io
</p>
</motion.div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const ClinicProfilePage = lazy(() => import('./pages/ClinicProfilePage'))
export const clinicsRoutes: RouteObject[] = [
{ path: 'clinic/:id', element: <ClinicProfilePage /> },
]

View File

@ -0,0 +1,604 @@
/**
* .
*
* (···radius) +
* ·· .
*
* dev-only
* import.meta.env.DEV .
*/
import { useState } from 'react';
import { Bell, Check, Heart, Settings, Trash2 } from 'lucide-react';
import { Button } from '@/shared/ui/button';
import { Badge } from '@/shared/ui/badge';
import { Input } from '@/shared/ui/input';
import { Textarea } from '@/shared/ui/textarea';
import { Label } from '@/shared/ui/label';
import { Checkbox } from '@/shared/ui/checkbox';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs';
import { Alert, AlertDescription, AlertTitle } from '@/shared/ui/alert';
import { Separator } from '@/shared/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/shared/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/shared/ui/select';
import * as FilledIcons from '@/shared/icons/FilledIcons';
/* ─────────────── 작은 보조 컴포넌트 ─────────────── */
function Swatch({
name,
value,
textOn = 'dark',
}: {
name: string;
value: string;
textOn?: 'dark' | 'light';
}) {
return (
<div className="flex flex-col gap-2">
<div
className="h-20 rounded-xl border border-slate-200 flex items-end p-2"
style={{ background: value }}
>
<span
className={`text-[10px] font-mono ${
textOn === 'dark' ? 'text-white/80' : 'text-slate-700'
}`}
>
{value}
</span>
</div>
<div className="text-xs font-mono text-slate-600">{name}</div>
</div>
);
}
function StatusPill({
label,
bg,
text,
border,
dot,
}: {
label: string;
bg: string;
text: string;
border: string;
dot: string;
}) {
return (
<div className="flex flex-col gap-3">
<div
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border w-fit"
style={{ background: bg, color: text, borderColor: border }}
>
<span className="w-2 h-2 rounded-full" style={{ background: dot }} />
{label}
</div>
<div className="grid grid-cols-2 gap-1 text-[10px] font-mono text-slate-500">
<span>bg: {bg}</span>
<span>text: {text}</span>
<span>border: {border}</span>
<span>dot: {dot}</span>
</div>
</div>
);
}
function SectionHeader({ title, desc }: { title: string; desc?: string }) {
return (
<div className="mb-8">
<h2 className="font-serif text-3xl font-bold">{title}</h2>
{desc && <p className="text-sm text-slate-500 mt-2">{desc}</p>}
</div>
);
}
function Section({
title,
desc,
dark = false,
children,
}: {
title: string;
desc?: string;
dark?: boolean;
children: React.ReactNode;
}) {
return (
<section className={`py-16 px-6 ${dark ? 'bg-primary-900 text-white' : ''}`}>
<div className="max-w-7xl mx-auto">
<SectionHeader title={title} desc={desc} />
{children}
</div>
</section>
);
}
/* ─────────────── 메인 페이지 ─────────────── */
export default function ComponentsPage() {
const [checked, setChecked] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
return (
<div className="pt-20 bg-white">
{/* Hero */}
<div className="px-6 py-20 bg-gradient-to-br from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]">
<div className="max-w-7xl mx-auto">
<p className="font-mono text-xs text-slate-600 uppercase tracking-widest mb-3">
Design System
</p>
<h1 className="font-serif text-5xl font-bold mb-3"> </h1>
<p className="text-slate-600 max-w-2xl">
····· .
/ .
</p>
</div>
</div>
{/* 1. Brand Colors */}
<Section title="1. 브랜드 컬러" desc="브랜드 팔레트 전체. 토큰 이름과 hex를 같이 표시.">
<div className="space-y-10">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">
Purple family
</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Swatch name="brand-purple" value="#4F1DA1" />
<Swatch name="brand-purple-deep" value="#021341" />
<Swatch name="brand-purple-vivid" value="#6C5CE7" />
<Swatch name="brand-purple-muted" value="#4A3A7C" />
<Swatch name="brand-purple-soft" value="#9B8AD4" />
<Swatch name="brand-purple-faint" value="#3A3F7C" />
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">
Navy (·)
</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Swatch name="brand-navy" value="#0A1128" />
<Swatch name="brand-navy-soft" value="#1A2B5E" />
<Swatch name="brand-navy-faint" value="#F4F6FB" textOn="light" />
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">
Tints (·)
</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Swatch name="brand-tint-purple" value="#F3F0FF" textOn="light" />
<Swatch name="brand-tint-lavender" value="#D5CDF5" textOn="light" />
<Swatch name="brand-tint-violet" value="#EFF0FF" textOn="light" />
<Swatch name="brand-tint-orchid" value="#C084CF" />
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">
Warm tones (Rose / Earth)
</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Swatch name="brand-rose" value="#7C3A4B" />
<Swatch name="brand-rose-mid" value="#D4889A" />
<Swatch name="brand-rose-soft" value="#F5D5DC" textOn="light" />
<Swatch name="brand-rose-bg" value="#FFF0F0" textOn="light" />
<Swatch name="brand-earth" value="#7C5C3A" />
<Swatch name="brand-earth-mid" value="#D4A872" />
<Swatch name="brand-earth-soft" value="#F5E0C5" textOn="light" />
<Swatch name="brand-earth-bg" value="#FFF6ED" textOn="light" />
</div>
</div>
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-4">
Periwinkle (info) + Gradient anchors
</h3>
<div className="grid grid-cols-3 md:grid-cols-6 gap-4">
<Swatch name="brand-periwinkle" value="#7A84D4" />
<Swatch name="brand-periwinkle-border" value="#C5CBF5" textOn="light" />
<Swatch name="brand-grad-peach" value="#FFF3EB" textOn="light" />
<Swatch name="brand-grad-violet" value="#E4CFFF" textOn="light" />
<Swatch name="brand-grad-sky" value="#F5F9FF" textOn="light" />
</div>
</div>
</div>
</Section>
<Separator />
{/* 2. Status semantic */}
<Section
title="2. 상태 색상"
desc="critical / warning / good / info — bg·text·border·dot 한 세트로 사용."
>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatusPill
label="Critical"
bg="#FFF0F0"
text="#7C3A4B"
border="#F5D5DC"
dot="#D4889A"
/>
<StatusPill
label="Warning"
bg="#FFF6ED"
text="#7C5C3A"
border="#F5E0C5"
dot="#D4A872"
/>
<StatusPill
label="Good"
bg="#F3F0FF"
text="#4A3A7C"
border="#D5CDF5"
dot="#9B8AD4"
/>
<StatusPill
label="Info"
bg="#EFF0FF"
text="#3A3F7C"
border="#C5CBF5"
dot="#7A84D4"
/>
</div>
</Section>
<Separator />
{/* 3. Typography */}
<Section
title="3. 타이포그래피"
desc="영문 헤딩 = Playfair Display, 한글·본문 = Pretendard Variable."
>
<div className="space-y-6">
<div className="border-b border-slate-100 pb-4">
<h1 className="font-serif text-6xl font-bold">Heading 1 6xl serif</h1>
<p className="text-xs font-mono text-slate-400 mt-1">font-serif text-6xl font-bold</p>
</div>
<div className="border-b border-slate-100 pb-4">
<h2 className="font-serif text-4xl font-bold">Heading 2 4xl serif</h2>
<p className="text-xs font-mono text-slate-400 mt-1">font-serif text-4xl font-bold</p>
</div>
<div className="border-b border-slate-100 pb-4">
<h3 className="font-serif text-2xl font-bold">Heading 3 2xl serif</h3>
<p className="text-xs font-mono text-slate-400 mt-1">font-serif text-2xl font-bold</p>
</div>
<div className="border-b border-slate-100 pb-4">
<p className="text-base">
Pretendard Variable. INFINITH AI SaaS.
</p>
<p className="text-xs font-mono text-slate-400 mt-1">text-base</p>
</div>
<div className="border-b border-slate-100 pb-4">
<p className="text-sm text-slate-600">
/ text-sm text-slate-600
</p>
</div>
<div className="border-b border-slate-100 pb-4">
<p className="text-xs font-mono text-slate-500">font-mono · text-xs · </p>
</div>
<div>
<p className="font-serif text-4xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">
INFINITH
</p>
<p className="text-xs font-mono text-slate-400 mt-1"> </p>
</div>
</div>
</Section>
<Separator />
{/* 4. Buttons */}
<Section title="4. 버튼" desc="5가지 변형 × 4가지 사이즈.">
<div className="space-y-8">
{(['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'] as const).map(
(variant) => (
<div key={variant}>
<p className="text-xs font-mono text-slate-500 mb-3">variant=&quot;{variant}&quot;</p>
<div className="flex flex-wrap items-center gap-3">
<Button variant={variant} size="lg">
Large
</Button>
<Button variant={variant} size="default">
Default
</Button>
<Button variant={variant} size="sm">
Small
</Button>
<Button variant={variant} size="xs">
Extra Small
</Button>
<Button variant={variant} size="icon" aria-label="settings">
<Settings />
</Button>
<Button variant={variant} disabled>
Disabled
</Button>
</div>
</div>
),
)}
</div>
</Section>
<Separator />
{/* 5. Badges */}
<Section title="5. 배지" desc="라벨·태그·상태 표시.">
<div className="flex flex-wrap items-center gap-3">
<Badge>Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="ghost">Ghost</Badge>
<Badge>
<Check />
</Badge>
<Badge variant="destructive">
<Heart />
</Badge>
</div>
</Section>
<Separator />
{/* 6. Forms */}
<Section title="6. 폼 컴포넌트" desc="Input / Textarea / Select / Checkbox / Label.">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl">
<div className="space-y-2">
<Label htmlFor="dev-input"> URL</Label>
<Input id="dev-input" placeholder="https://example.com" />
<p className="text-xs text-slate-500"> . .</p>
</div>
<div className="space-y-2">
<Label htmlFor="dev-input-err"> </Label>
<Input id="dev-input-err" aria-invalid placeholder="잘못된 입력" />
<p className="text-xs text-status-critical-text"> URL .</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="dev-textarea"></Label>
<Textarea id="dev-textarea" placeholder="여러 줄 입력..." rows={4} />
</div>
<div className="space-y-2">
<Label htmlFor="dev-select"></Label>
<Select>
<SelectTrigger id="dev-select">
<SelectValue placeholder="플랜 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="starter">Starter</SelectItem>
<SelectItem value="pro">Professional</SelectItem>
<SelectItem value="agency">Agency</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label> </Label>
<div className="flex items-center gap-2">
<Checkbox
id="dev-check"
checked={checked}
onCheckedChange={(v) => setChecked(v === true)}
/>
<Label htmlFor="dev-check" className="font-normal cursor-pointer">
</Label>
</div>
</div>
</div>
</Section>
<Separator />
{/* 7. Cards */}
<Section title="7. 카드" desc="기본 카드 + glass-card 효과.">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>shadcn Card .</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-600">
·· . .
</p>
</CardContent>
</Card>
<div className="glass-card p-6">
<h3 className="font-serif text-xl font-bold mb-2">Glass Card</h3>
<p className="text-sm text-slate-600">
+ blur. .
</p>
<p className="text-xs font-mono text-slate-400 mt-3">.glass-card</p>
</div>
<Card className="border-brand-purple-vivid/40 bg-brand-tint-purple">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-brand-purple-muted">
background·border .
</p>
</CardContent>
</Card>
</div>
</Section>
<Separator />
{/* 8. Alerts */}
<Section title="8. 알림 박스" desc="페이지 내 인라인 알림.">
<div className="space-y-4 max-w-3xl">
<Alert>
<Bell className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
. 1~3 .
</AlertDescription>
</Alert>
<Alert variant="destructive">
<Trash2 className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
URL . (variant=&quot;destructive&quot;)
</AlertDescription>
</Alert>
</div>
</Section>
<Separator />
{/* 9. Tabs */}
<Section title="9. 탭" desc="Tabs / TabsList / TabsTrigger / TabsContent.">
<Tabs defaultValue="overview" className="max-w-3xl">
<TabsList>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="channels"></TabsTrigger>
<TabsTrigger value="kpi">KPI</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="p-6 border border-slate-200 rounded-xl mt-4">
<p className="text-sm text-slate-600"> .</p>
</TabsContent>
<TabsContent value="channels" className="p-6 border border-slate-200 rounded-xl mt-4">
<p className="text-sm text-slate-600"> .</p>
</TabsContent>
<TabsContent value="kpi" className="p-6 border border-slate-200 rounded-xl mt-4">
<p className="text-sm text-slate-600">KPI .</p>
</TabsContent>
</Tabs>
</Section>
<Separator />
{/* 10. Dialog */}
<Section title="10. 다이얼로그" desc="모달 — Dialog / DialogTrigger 패턴.">
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button> </Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle> ?</DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button variant="destructive" onClick={() => setDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Section>
{/* 11. 다크 섹션 (룰 데모) */}
<Section
title="11. 다크 섹션 (규칙 데모)"
desc="다크 배경(#0A1128) 위에는 흰 카드(bg-white + 그림자) 사용. glass-card 아님."
dark
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-2xl shadow-lg p-6">
<h3 className="font-serif text-xl font-bold mb-2 text-brand-navy"> </h3>
<p className="text-sm text-slate-600">
. glass-card .
</p>
</div>
<div className="bg-white rounded-2xl shadow-lg p-6">
<Badge></Badge>
<p className="mt-3 text-sm text-slate-600">· .</p>
</div>
<div className="bg-white rounded-2xl shadow-lg p-6">
<Button> </Button>
<p className="mt-3 text-sm text-slate-600"> .</p>
</div>
</div>
</Section>
<Separator />
{/* 12. Lucide icons */}
<Section title="12. 아이콘 — Lucide" desc="기본 아이콘 세트. line 스타일.">
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{[
{ icon: Bell, name: 'Bell' },
{ icon: Check, name: 'Check' },
{ icon: Heart, name: 'Heart' },
{ icon: Settings, name: 'Settings' },
{ icon: Trash2, name: 'Trash2' },
].map(({ icon: Icon, name }) => (
<div
key={name}
className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-xl"
>
<Icon className="w-6 h-6 text-brand-navy" />
<span className="text-xs font-mono text-slate-500">{name}</span>
</div>
))}
</div>
</Section>
<Separator />
{/* 13. Filled Icons */}
<Section
title="13. 아이콘 — FilledIcons"
desc="브랜드 전용 채워진 SVG 아이콘 세트. 다크 섹션이나 카드 헤더에서 강조용."
>
<div className="grid grid-cols-4 md:grid-cols-8 gap-4">
{Object.entries(FilledIcons)
.filter(([, v]) => typeof v === 'function')
.map(([name, Comp]) => {
const Icon = Comp as React.ComponentType<{ size?: number; className?: string }>;
return (
<div
key={name}
className="flex flex-col items-center gap-2 p-4 border border-slate-200 rounded-xl"
>
<Icon size={28} className="text-brand-purple-vivid" />
<span className="text-[10px] font-mono text-slate-500 text-center break-all">
{name}
</span>
</div>
);
})}
</div>
</Section>
{/* Footer note */}
<div className="px-6 py-12 bg-slate-50 text-center">
<p className="text-xs font-mono text-slate-500">
design system catalog · /dev/components ·
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
import { lazy } from 'react'
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
export const devRoutes = [
{ path: 'dev/components', element: <ComponentsPage /> },
]

View File

@ -0,0 +1,50 @@
/**
* Distribution mock .
*
* - MOCK_CONTENT: Studio (·· )
* - INITIAL_CHANNELS: ( /)
*/
import type { ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '@/shared/icons/FilledIcons';
export type DistributeStatus = 'ready' | 'publishing' | 'published' | 'failed';
export interface ChannelTarget {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
connected: boolean;
selected: boolean;
status: DistributeStatus;
format: string;
}
export const MOCK_CONTENT = {
title: '한번에 성공하는 코성형, VIEW의 비결',
description:
'코성형은 얼굴의 중심을 결정하는 중요한 수술입니다. VIEW 성형외과는 21년간 축적된 노하우를 바탕으로 자연스러운 결과를 만들어냅니다.',
type: 'video' as const,
duration: '0:58',
aspectRatio: '9:16',
tags: ['코성형', '뷰성형외과', '강남성형외과', '코수술', '자연스러운코'],
thumbnail: null as string | null,
};
export const INITIAL_CHANNELS: ChannelTarget[] = [
{ id: 'youtube', name: 'YouTube Shorts', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', connected: true, selected: true, status: 'ready', format: 'Shorts (9:16)' },
{ id: 'instagram_kr', name: 'Instagram Reels', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (9:16)' },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
{ id: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
{ id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
{ id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, selected: false, status: 'ready', format: 'Video Post' },
];

View File

@ -0,0 +1,356 @@
import { useState, useCallback } from 'react';
import { motion } from 'motion/react';
import {
VideoFilled,
ShareFilled,
} from '@/shared/icons/FilledIcons';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import { Textarea } from '@/shared/ui/textarea';
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../data/distributionMocks';
// ─── 컴포넌트 ───
export default function DistributionPage() {
const [channels, setChannels] = useState(INITIAL_CHANNELS);
const [title, setTitle] = useState(MOCK_CONTENT.title);
const [description, setDescription] = useState(MOCK_CONTENT.description);
const [tags] = useState(MOCK_CONTENT.tags);
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleHour, setScheduleHour] = useState(9);
const [scheduleMinute, setScheduleMinute] = useState(0);
const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM');
const [isPublishing, setIsPublishing] = useState(false);
const selectedChannels = channels.filter(c => c.connected && c.selected);
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
const toggleChannel = useCallback((id: string) => {
setChannels(prev => prev.map(c =>
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
));
}, []);
const handlePublish = useCallback(() => {
setIsPublishing(true);
// 순차 발행 시뮬레이션
const selected = channels.filter(c => c.connected && c.selected);
selected.forEach((ch, i) => {
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'publishing' } : c
));
}, i * 1500);
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'published' } : c
));
if (i === selected.length - 1) setIsPublishing(false);
}, (i + 1) * 1500 + 500);
});
}, [channels]);
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-brand-navy py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-brand-purple-vivid/10 blur-[120px]" />
<div className="max-w-5xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">
</h1>
<p className="text-purple-200/70 max-w-xl">
.
</p>
</div>
</div>
<div className="max-w-5xl mx-auto px-6 py-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left: Content Preview + Meta */}
<div className="lg:col-span-1">
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4"></h3>
{/* Video Preview */}
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-brand-tint-purple via-white to-brand-earth-bg border border-slate-200 flex flex-col items-center justify-center mb-6">
<VideoFilled size={32} className="text-brand-purple-soft mb-3" />
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
</div>
{/* Title */}
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<Input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-4 py-3 h-auto rounded-xl border-slate-200 text-sm text-slate-700 focus-visible:border-brand-purple-vivid focus-visible:ring-brand-purple-vivid/20"
/>
</div>
{/* Description */}
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<Textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-3 rounded-xl border-slate-200 text-sm text-slate-700 resize-y focus-visible:border-brand-purple-vivid focus-visible:ring-brand-purple-vivid/20"
/>
</div>
{/* Tags */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span
key={tag}
className="px-3 py-1 rounded-full bg-brand-tint-purple text-brand-purple-muted text-xs font-medium border border-brand-tint-lavender"
>
#{tag}
</span>
))}
</div>
</div>
</div>
{/* Right: Channel Selection + Schedule + Publish */}
<div className="lg:col-span-2">
{/* Channel Selection */}
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4"> </h3>
<div className="space-y-3 mb-8">
{channels.map(ch => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
layout
className={`flex items-center gap-4 p-4 rounded-2xl border-2 transition-all ${
!ch.connected
? 'border-slate-100 bg-slate-50/50 opacity-50'
: ch.selected
? 'border-brand-purple-vivid bg-brand-tint-purple/20 shadow-[3px_4px_12px_rgba(108,92,231,0.08)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)] hover:border-brand-tint-lavender'
}`}
>
{/* Checkbox */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => toggleChannel(ch.id)}
disabled={!ch.connected}
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 p-0 transition-all hover:bg-transparent ${
ch.selected && ch.connected
? 'border-brand-purple-vivid bg-brand-purple-vivid hover:bg-brand-purple-vivid'
: 'border-slate-300 bg-white'
} disabled:cursor-not-allowed`}
>
{ch.selected && ch.connected && (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</Button>
{/* Icon */}
<div
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-brand-navy">{ch.name}</span>
{!ch.connected && (
<span className="px-2 py-1 rounded-full bg-brand-earth-bg text-brand-earth text-xs font-semibold border border-brand-earth-soft">
</span>
)}
</div>
<p className="text-xs text-slate-400">{ch.format}</p>
</div>
{/* Status */}
<div className="shrink-0">
{ch.status === 'publishing' && (
<div className="w-5 h-5 border-2 border-brand-purple-vivid border-t-transparent rounded-full animate-spin" />
)}
{ch.status === 'published' && (
<div className="w-6 h-6 rounded-full bg-brand-purple-vivid flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
{ch.status === 'failed' && (
<div className="w-6 h-6 rounded-full bg-brand-rose-bg flex items-center justify-center border border-brand-rose-soft">
<span className="text-brand-rose text-xs font-bold">!</span>
</div>
)}
</div>
</motion.div>
);
})}
</div>
{/* Schedule */}
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4"> </h3>
<div className="flex gap-2 mb-4">
{([
{ key: 'now' as const, label: '즉시 배포' },
{ key: 'scheduled' as const, label: '예약 배포' },
]).map(opt => (
<Button
key={opt.key}
variant="ghost"
onClick={() => setScheduleMode(opt.key)}
className={`px-5 py-3 h-auto rounded-full text-sm font-medium transition-all ${
scheduleMode === opt.key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-md hover:from-brand-purple hover:to-brand-purple-deep hover:text-white'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{opt.label}
</Button>
))}
</div>
{scheduleMode === 'scheduled' && (
<div className="mb-6 space-y-4">
{/* Date */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<Input
type="date"
value={scheduleDate}
onChange={e => setScheduleDate(e.target.value)}
className="w-full px-4 py-3 h-auto rounded-xl border-slate-200 text-sm text-slate-700 focus-visible:border-brand-purple-vivid appearance-none"
/>
</div>
{/* Custom Time Picker */}
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex items-center gap-3">
{/* Hour */}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</Button>
<div className="w-12 h-10 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender flex items-center justify-center text-lg font-semibold text-brand-navy">
{String(scheduleHour).padStart(2, '0')}
</div>
<Button
variant="outline"
size="icon-sm"
onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)}
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</Button>
</div>
<span className="text-xl font-bold text-slate-300">:</span>
{/* Minute */}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon-sm"
onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</Button>
<div className="w-12 h-10 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender flex items-center justify-center text-lg font-semibold text-brand-navy">
{String(scheduleMinute).padStart(2, '0')}
</div>
<Button
variant="outline"
size="icon-sm"
onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)}
className="w-8 h-8 rounded-lg bg-slate-50 border-slate-200 text-slate-500 hover:bg-slate-100"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</Button>
</div>
{/* AM/PM */}
<div className="flex rounded-xl overflow-hidden border border-slate-200">
{(['AM', 'PM'] as const).map(p => (
<Button
key={p}
variant="ghost"
onClick={() => setSchedulePeriod(p)}
className={`px-4 py-2 h-auto rounded-none text-sm font-medium transition-all ${
schedulePeriod === p
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white hover:from-brand-purple hover:to-brand-purple-deep hover:text-white'
: 'bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{p}
</Button>
))}
</div>
</div>
</div>
</div>
)}
{/* Publish Button */}
{!allPublished ? (
<Button
onClick={handlePublish}
disabled={selectedChannels.length === 0 || isPublishing}
className="w-full h-auto flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{isPublishing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
... ({selectedChannels.filter(c => c.status === 'published').length}/{selectedChannels.length})
</>
) : (
<>
<ShareFilled size={18} className="text-white" />
{selectedChannels.length} {scheduleMode === 'now' ? '즉시 배포' : '예약 배포'}
</>
)}
</Button>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full py-6 rounded-2xl bg-brand-tint-purple border border-brand-tint-lavender text-center"
>
<div className="w-12 h-12 rounded-full bg-brand-purple-vivid flex items-center justify-center mx-auto mb-3">
<svg width="20" height="20" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-lg font-semibold text-brand-navy mb-1"> </p>
<p className="text-sm text-brand-purple-muted">
{selectedChannels.length}
</p>
</motion.div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const DistributionPage = lazy(() => import('./pages/DistributionPage'))
export const distributionRoutes: RouteObject[] = [
{ path: 'distribute', element: <DistributionPage /> },
]

View File

@ -0,0 +1,41 @@
import React from 'react';
type Variant = 'popular' | 'new' | 'beta';
type Size = 'sm' | 'md' | 'lg';
interface BadgeProps {
variant?: Variant;
size?: Size;
label?: string;
className?: string;
}
const variantStyles: Record<Variant, { bg: string; text: string; defaultLabel: string }> = {
popular: { bg: 'bg-accent', text: 'text-white', defaultLabel: '인기' },
new: { bg: 'bg-emerald-500', text: 'text-white', defaultLabel: 'NEW' },
beta: { bg: 'bg-slate-700', text: 'text-white', defaultLabel: 'BETA' },
};
const sizeStyles: Record<Size, string> = {
sm: 'text-[10px] px-2 py-0.5',
md: 'text-xs px-3 py-1',
lg: 'text-sm px-4 py-1.5',
};
const Badge: React.FC<BadgeProps> = ({
variant = 'popular',
size = 'md',
label,
className = '',
}) => {
const v = variantStyles[variant];
return (
<span
className={`inline-flex items-center font-semibold tracking-wide rounded-full shadow-sm ${v.bg} ${v.text} ${sizeStyles[size]} ${className}`}
>
{label ?? v.defaultLabel}
</span>
);
};
export default Badge;

View File

@ -0,0 +1,75 @@
/**
* CTA .
*
* PART III :
* - Hero (url state/navigate) .
* `<MultiChannelInput variant="cta" />` .
* - "무료 진단" .
* - ("Own Your Marketing Strategy.").
* - CTA "가격 플랜 보기" (/pricing?from=cta).
*/
import { useNavigate, Link } from 'react-router';
import { motion } from 'motion/react';
import MultiChannelInput, { type AnalyzePayload } from '@/features/channels/components/MultiChannelInput';
export default function CTA() {
const navigate = useNavigate();
const handleAnalyze = (payload: AnalyzePayload) => {
navigate('/report/loading', {
state: {
url: payload.primaryUrl,
manualChannels: payload.manualChannels,
},
});
};
return (
<section className="py-20 md:py-24 bg-primary-900 text-white px-6 relative overflow-hidden">
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-indigo-900 via-primary-900 to-primary-900 opacity-80"></div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-purple-500/20 rounded-full blur-[100px] pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-4xl md:text-5xl font-serif font-bold mb-4 leading-tight text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]"
>
Own Your Marketing Strategy.
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-lg md:text-xl text-purple-200 mb-10 max-w-2xl mx-auto font-light"
>
URL AI .
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="w-full"
>
<MultiChannelInput variant="cta" onAnalyze={handleAnalyze} />
{/* 보조 CTA — 가격 플랜 */}
<div className="mt-6 flex justify-center">
<Link
to="/pricing?from=cta"
className="inline-flex items-center gap-2 text-sm font-semibold text-purple-200 hover:text-white transition-colors underline-offset-4 hover:underline"
>
</Link>
</div>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,79 @@
import { useNavigate } from 'react-router';
import { motion } from 'motion/react';
import { PrismFilled } from '@/shared/icons/FilledIcons';
import MultiChannelInput, { type AnalyzePayload } from '@/features/channels/components/MultiChannelInput';
export default function Hero() {
const navigate = useNavigate();
const handleAnalyze = (payload: AnalyzePayload) => {
navigate('/report/loading', {
state: {
url: payload.primaryUrl,
manualChannels: payload.manualChannels,
},
});
};
return (
<section className="relative pt-28 pb-12 md:pt-36 md:pb-16 overflow-hidden flex flex-col items-center justify-center text-center px-6">
{/* Background Gradient */}
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-70"></div>
<div className="max-w-4xl mx-auto relative isolate">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 shadow-sm mb-8"
>
<PrismFilled size={16} className="text-accent" />
<span className="text-sm font-medium text-slate-700">AI Marketing Strategist · Built for Premium Clinics</span>
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl md:text-7xl font-serif font-bold text-primary-900 leading-[1.1] tracking-[-0.02em] mb-6"
>
<span className="tracking-[0.05em]">In<span className="ml-[-0.04em]">f</span><span className="ml-[-0.04em]">inite</span></span> Growth <br className="hidden md:block" />
<span className="text-gradient">Marketing Engine.</span>
</motion.h1>
{/* PART II : Strategic Planner
"Marketing that learns... 쓸수록 더 정교해지는..."
Generation/Distribution Mock .
Phase 1-4 (DiscoverCollectReportPlan) . */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-lg md:text-xl text-slate-600 mb-10 max-w-3xl mx-auto leading-relaxed"
>
The Strategic Planner for Premium Medical Marketing. <br className="hidden md:block" />
<span className="whitespace-nowrap">···</span>{' '}
<span className="whitespace-nowrap">10 ,</span>{' '}
<span className="whitespace-nowrap"> 12 </span>{' '}
<span className="whitespace-nowrap">.</span>
</motion.p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
className="w-full"
>
<MultiChannelInput variant="hero" onAnalyze={handleAnalyze} />
</motion.div>
</div>
{/* Decorative elements — isolate prevents mix-blend-multiply from bleeding into content */}
<div className="absolute inset-0 -z-[5] isolate pointer-events-none">
<div className="absolute top-1/4 left-10 w-64 h-64 bg-purple-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob"></div>
<div className="absolute top-1/3 right-10 w-64 h-64 bg-pink-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 w-64 h-64 bg-indigo-300 rounded-full mix-blend-multiply filter blur-3xl opacity-30 animate-blob animation-delay-4000"></div>
</div>
</section>
);
}

View File

@ -0,0 +1,192 @@
import React from 'react';
import { motion } from 'motion/react';
// PART II 피봇: 1·5번은 Product 1.0 Available, 2·3·4는 Coming Soon.
// 카피는 Intelligence + Planning 중심으로 조정 (구조 5개 카드는 유지).
const modules = [
{
step: "1",
title: "Marketing Intelligence",
items: [
"브랜드·온라인 프레즌스 진단",
"유튜브·인스타·네이버·강남언니 실측",
"경쟁 병원 벤치마크 (최대 10곳)",
"키워드·해시태그 트렌드 분석",
"Vision AI 기반 의료진·슬로건 추출",
],
highlight: "10분 진단 → 전략 기획의 출발점",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "2",
title: "Strategic Planning",
items: [
"12개월 마케팅 로드맵",
"4~8주 콘텐츠 캘린더 + 필러 5종",
"KPI 대시보드 (3·12개월 목표)",
"주간 KPI 달성도 → 전략 조정 제안",
"브랜드 가이드 (톤·컬러·로고) 자동 추출",
],
highlight: "관찰을 실행 가능한 전략으로 전환",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "3",
title: "Content Creation",
items: [
"블로그·SEO·SNS 카피 자동 생성",
"브랜드 톤 일관성 보장",
"Human-in-the-loop 편집 워크플로우",
"콘텐츠 필러·시즌별 테마 매핑",
"Coming Soon · Q4 2026",
],
highlight: "전략을 실행 가능한 콘텐츠로",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "4",
title: "Video Automation",
items: [
"블로그 → 숏폼·유튜브 영상 변환",
"Creatomate 기반 자동 렌더링",
"음악·자막·썸네일 AI 생성",
"시즌별 템플릿 + 병원 CI 반영",
"Coming Soon · Q4 2026",
],
highlight: "영상 제작 리소스 부담 해소",
color: "bg-[#021341]",
textColor: "text-indigo-600"
},
{
step: "5",
title: "Distribution & Performance",
items: [
"멀티 채널 자동 게시 (블로그·SNS·유튜브)",
"SEO·AEO 자동 최적화",
"실시간 성과 트래킹 + 리포트",
"KPI 달성 → 다음 주기 전략 피드백",
"Coming Soon · Q4 2026",
],
highlight: "전략-실행-성과의 자율 루프 완성",
color: "bg-[#021341]",
textColor: "text-indigo-600"
}
];
const Flywheel = () => (
<div className="relative w-72 h-72 md:w-[400px] md:h-[400px] flex items-center justify-center">
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center z-10">
<h3 className="text-[26px] md:text-[31px] font-serif font-bold text-slate-800 leading-tight">
Self-Improving<br/>Growth Engine
</h3>
</div>
</div>
);
const ModuleCard: React.FC<{ mod: any, className?: string }> = ({ mod, className }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className={`bg-white rounded-2xl p-5 md:p-6 shadow-xl shadow-slate-200/50 border border-slate-100 flex flex-col ${className}`}
>
<div className="flex items-center gap-3 mb-5">
<div className={`w-10 h-10 shrink-0 rounded-xl flex items-center justify-center text-white font-bold text-lg ${mod.color}`}>
{mod.step}
</div>
<h3 className="text-lg md:text-xl font-bold text-slate-800 leading-tight tracking-tight">{mod.title}</h3>
</div>
<ul className="space-y-3 mb-6 flex-grow">
{mod.items.map((item: string, i: number) => (
<li key={i} className="text-slate-600 text-sm md:text-base leading-relaxed break-keep">
{item}
</li>
))}
</ul>
<div className={`mt-auto pt-4 border-t border-slate-100 font-bold text-sm md:text-base tracking-tight ${mod.textColor}`}>
{mod.highlight}
</div>
</motion.div>
);
export default function Modules() {
return (
<section id="modules" className="py-24 md:py-32 bg-white px-6 overflow-hidden relative">
{/* Animated Background Blobs */}
<div className="absolute top-[-10%] left-[-10%] w-[50vw] h-[50vw] min-w-[600px] min-h-[600px] rounded-full bg-[#fff3eb] opacity-80 blur-[120px] animate-blob-large pointer-events-none"></div>
<div className="absolute top-[20%] right-[-10%] w-[40vw] h-[40vw] min-w-[500px] min-h-[500px] rounded-full bg-[#e4cfff] opacity-40 blur-[120px] animate-blob-large animation-delay-7000 pointer-events-none"></div>
<div className="absolute bottom-[-10%] left-[20%] w-[60vw] h-[60vw] min-w-[700px] min-h-[700px] rounded-full bg-[#f5f9ff] opacity-80 blur-[120px] animate-blob-large animation-delay-14000 pointer-events-none"></div>
<div className="max-w-7xl mx-auto relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16 md:mb-24"
>
<h2 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 mb-6">
Core Modules
</h2>
{/* PART II : " " Phase 1-4 .
Product 1.0 Available 2(Marketing Intelligence + Strategic Planning)
Coming Soon 3(Content/Video/Distribution) . */}
<p className="text-lg md:text-xl text-slate-600 max-w-2xl mx-auto break-keep">
<strong className="text-primary-900">Product 1.0 </strong> .
</p>
</motion.div>
{/* Desktop Layout — Pentagon around Flywheel */}
<div className="hidden lg:block relative max-w-[1200px] mx-auto mt-10" style={{ height: '960px' }}>
{/* Center flywheel */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<Flywheel />
</div>
{/* Pentagon: 5 cards around center, all same width */}
{/* 1. Top center */}
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: '0px' }}>
<ModuleCard mod={modules[0]} className="w-[300px]" />
</div>
{/* 2. Upper right */}
<div className="absolute" style={{ right: '20px', top: '130px' }}>
<ModuleCard mod={modules[1]} className="w-[300px]" />
</div>
{/* 3. Lower right */}
<div className="absolute" style={{ right: '80px', bottom: '30px' }}>
<ModuleCard mod={modules[2]} className="w-[300px]" />
</div>
{/* 4. Lower left */}
<div className="absolute" style={{ left: '80px', bottom: '30px' }}>
<ModuleCard mod={modules[3]} className="w-[300px]" />
</div>
{/* 5. Upper left */}
<div className="absolute" style={{ left: '20px', top: '130px' }}>
<ModuleCard mod={modules[4]} className="w-[300px]" />
</div>
</div>
{/* Mobile/Tablet Layout (Flex Column) */}
<div className="lg:hidden flex flex-col gap-8 items-center">
<Flywheel />
<div className="w-full grid md:grid-cols-2 gap-6 mt-8">
{modules.map((mod, idx) => (
<ModuleCard key={idx} mod={mod} className="w-full" />
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,61 @@
import { motion } from 'motion/react';
// PART II 피봇: 현장 고충 중심으로 재작성. #3 "데이터 기반의 마케팅 부족"은 유지(사용자 피드백).
const problems = [
{
title: "콘텐츠, 소진되는 비용과 시간",
desc: "매주 쏟아지는 콘텐츠 제작 요구에 인력·시간·예산이 빠르게 소진됩니다. ROI를 측정할 여력도 부족합니다."
},
{
title: "트렌드와 경쟁사 분석 부재",
desc: "강남언니·네이버·유튜브에서 경쟁 병원이 어떤 콘텐츠·메시지로 움직이는지 체계적으로 추적할 방법이 없습니다.",
highlight: true
},
{
title: "데이터 기반의 마케팅 부족",
desc: "콘텐츠·광고·채널 성과가 어디에서 어떻게 작동하는지 데이터로 증명하기 어렵고, 의사결정은 감에 의존합니다."
}
];
export default function Problems() {
return (
<section className="py-24 bg-slate-50 px-6 relative overflow-hidden">
<div className="max-w-6xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Premium Medical Marketing is Hard
</h2>
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
3
</p>
</motion.div>
<div className="grid md:grid-cols-3 gap-6">
{problems.map((problem, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: idx * 0.1 }}
className="bg-white rounded-2xl p-8 md:p-10 border border-slate-100 shadow-sm hover:shadow-md transition-shadow flex flex-col justify-center"
>
<h3 className={`text-2xl font-bold mb-4 ${problem.highlight ? 'text-transparent bg-clip-text bg-gradient-to-r from-indigo-600 to-purple-600' : 'text-primary-900'}`}>
{problem.title}
</h3>
<p className="text-slate-600 text-base leading-relaxed">
{problem.desc}
</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,149 @@
import { motion } from 'motion/react';
import { Sparkles, ArrowRight } from 'lucide-react';
export default function Solution() {
return (
<section id="solution" className="py-32 bg-primary-900 text-white px-6 relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-accent/20 rounded-full blur-[120px] pointer-events-none"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 mb-8"
>
<Sparkles className="w-4 h-4 text-purple-300" />
<span className="text-sm font-medium text-purple-100">Strategic Planning Engine</span>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-4xl md:text-6xl font-serif font-bold mb-6 leading-tight text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff]"
>
The Strategic Planning Engine
</motion.h2>
{/* PART II : " AI "
AGDP . */}
<motion.p
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-xl md:text-2xl text-slate-300 mb-12 max-w-3xl mx-auto leading-relaxed font-light break-keep"
>
<span className="font-medium text-white"> , .</span>
<br className="hidden md:block" />
INFINITH <strong className="text-white font-medium">Audit Generation Direction Planning</strong> AGDP . ··· 12 KPI .
</motion.p>
{/* Circular Loop Diagram */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
className="relative w-full max-w-[320px] md:max-w-[500px] aspect-square mx-auto mt-16 mb-24 md:mb-32"
>
{/* Static Inner Ring */}
<div className="absolute top-[20%] left-[20%] right-[20%] bottom-[20%] rounded-full border border-white/5 shadow-[0_0_40px_rgba(255,255,255,0.02)_inset]"></div>
{/* Animated Glowing Ring */}
<svg className="absolute inset-0 w-full h-full animate-[spin_20s_linear_infinite]" viewBox="0 0 100 100">
<defs>
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#a78bfa" stopOpacity="0.1" />
<stop offset="50%" stopColor="#c084fc" stopOpacity="0.5" />
<stop offset="100%" stopColor="#e879f9" stopOpacity="0.1" />
</linearGradient>
</defs>
<circle cx="50" cy="50" r="30" fill="none" stroke="url(#ringGrad)" strokeWidth="0.5" strokeDasharray="2 2" />
</svg>
{/* Center Orb */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<div className="w-40 h-40 md:w-56 md:h-56 rounded-full bg-primary-900/90 backdrop-blur-xl border border-white/5 flex flex-col items-center justify-center shadow-[0_0_60px_rgba(167,139,250,0.1)]">
<span className="text-4xl md:text-6xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] mb-2">AGDP</span>
<h3 className="text-xl md:text-3xl font-serif font-bold text-white text-center leading-tight">Strategic<br/>Planning</h3>
</div>
</div>
{/* Node A: Audit (Left) — 구 Analysis. 병원·채널 진단 리포트 */}
<div className="absolute top-1/2 left-0 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">A</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Audit</span>
</div>
</div>
{/* Node G: Generation (Top) — 전략·로드맵 문서 생성 (AI 콘텐츠 생성 아님) */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">G</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Generation</span>
</div>
</div>
{/* Node D: Direction (Right) — 구 Distribution. 채널별 전략·우선순위 설계 */}
<div className="absolute top-1/2 right-0 translate-x-1/2 -translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">D</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Direction</span>
</div>
</div>
{/* Node P: Planning (Bottom) — 구 Performance. KPI 목표 설정 + 주간 조정 */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 z-30 flex flex-col items-center gap-3 md:gap-4">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border border-purple-500/30 bg-primary-900/80 backdrop-blur-sm flex items-center justify-center shadow-[0_0_20px_rgba(168,85,247,0.15)]">
<span className="text-3xl md:text-4xl font-bold text-purple-300">P</span>
</div>
<div className="text-center">
<span className="block text-lg md:text-2xl font-medium text-purple-200 leading-tight">Planning</span>
</div>
</div>
{/* Reward Signal Curved Text (P to A) */}
<svg className="absolute inset-0 w-full h-full pointer-events-none z-20" viewBox="0 0 100 100">
<defs>
<path id="rewardPath" d="M 10.6 56.9 A 40 40 0 0 0 43.1 89.4" fill="none" />
</defs>
{/* "Reward Signal" "KPI Feedback" .
KPI . */}
<text fontSize="3.5" className="font-medium uppercase tracking-widest" fill="#d8b4fe" opacity="0.8">
<textPath href="#rewardPath" startOffset="50%" textAnchor="middle">
KPI FEEDBACK
</textPath>
</text>
</svg>
</motion.div>
{/* AGDP Cycle Description */}
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.5 }}
className="max-w-3xl mx-auto mt-12 text-center"
>
<div className="inline-block bg-white/5 border border-white/10 rounded-2xl px-6 py-4 backdrop-blur-sm">
<p className="text-sm md:text-base text-slate-300 break-keep">
<span className="font-bold text-purple-300">AGDP Cycle:</span> (Audit) (Generation) (Direction) KPI (Planning) <span className="text-white font-medium"> KPI </span> .
</p>
</div>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,74 @@
import { motion } from 'motion/react';
export default function TargetAudience() {
return (
<section className="py-24 bg-white px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Who is Infinite Marketing for
</h2>
{/* PART II 피봇: 프리미엄 병원 메인, 대행사 보조. "전략 파트너·전략 자문" 포지셔닝 반영. */}
<p className="text-lg text-slate-600 max-w-2xl mx-auto break-keep leading-relaxed">
<strong className="text-primary-900"> </strong> <strong className="text-primary-900"> </strong> AI .
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-8">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-slate-50 to-white border border-slate-100 rounded-3xl"
>
<h3 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 mb-6 leading-tight">Premium Medical Business</h3>
<p className="text-slate-600 mb-10 leading-relaxed text-lg break-keep">
LTV <strong className="text-primary-900"> </strong>. ··· 10 12 .
</p>
<div className="flex flex-wrap gap-3">
{['피부과', '성형외과', '치과', '안과', '헬스케어 클리닉', '피트니스'].map((item, i) => (
<div key={i} className="bg-white px-5 py-3 rounded-2xl shadow-sm border border-slate-100 text-slate-700 font-medium hover:shadow-md transition-shadow">
{item}
</div>
))}
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-purple-50 to-white border border-purple-100/50 rounded-3xl"
>
<div className="flex items-center gap-3 mb-6 flex-wrap">
<h3 className="text-4xl md:text-5xl font-serif font-bold text-primary-900 leading-tight">Medical Marketing Agency</h3>
{/* Partner Program "" "" waitlist .
2 early-access . */}
<span className="inline-flex items-center px-2.5 py-1 rounded-full bg-purple-100 text-purple-700 text-xs font-semibold border border-purple-200">
Partner Program ·
</span>
</div>
<p className="text-slate-600 mb-10 leading-relaxed text-lg break-keep">
<strong className="text-primary-900"> </strong>. .
</p>
<div className="flex flex-wrap gap-3">
{['병원 마케팅 대행사', '콘텐츠 마케팅 Agency', '영상 마케팅 Agency', '광고 운영 대행사'].map((item, i) => (
<div key={i} className="bg-white px-5 py-3 rounded-2xl shadow-sm border border-purple-50 text-slate-700 font-medium hover:shadow-md transition-shadow">
{item}
</div>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,74 @@
import { motion } from 'motion/react';
import { CheckCircle2 } from 'lucide-react';
export default function UseCases() {
return (
<section id="use-cases" className="py-24 bg-white px-6">
<div className="max-w-7xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}
className="text-center mb-16"
>
<h2 className="text-3xl md:text-5xl font-serif font-bold text-primary-900 mb-4">
Use Cases
</h2>
{/* PART II 피봇: "만드는 실질적인 변화" (제품 중심) → 역할별 가치 (고객 중심)로 전환. */}
<p className="text-lg text-slate-600 max-w-2xl mx-auto font-medium break-keep">
INFINITH .
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-12">
<motion.div
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.1 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-blue-50/50 to-white border border-blue-100/30"
>
<h3 className="text-2xl md:text-3xl font-serif font-bold text-primary-900 mb-8">Premium Medical Business</h3>
{/* 체크리스트 재설계: 진단(Audit) · 전략(Planning) · 운영(Weekly KPI) 3축. */}
<ul className="space-y-6">
{[
'10분 진단으로 채널별 강·약점 파악, 투자 우선순위 즉시 도출',
'12개월 마케팅 로드맵과 콘텐츠 캘린더로 기획 리소스 70% 절감',
'주간 KPI 달성도 → 전략 조정 루프로 환자 전환 흐름 자율 최적화',
].map((item, i) => (
<li key={i} className="flex items-start gap-4 text-slate-700 font-medium group">
<CheckCircle2 className="w-6 h-6 text-[#7A84D4] shrink-0 mt-1" />
<span className="leading-relaxed text-lg group-hover:text-primary-900 transition-colors">{item}</span>
</li>
))}
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}
className="glass-card p-10 md:p-12 bg-gradient-to-br from-purple-50/50 to-white border border-purple-100/30"
>
<h3 className="text-2xl md:text-3xl font-serif font-bold text-primary-900 mb-8">Marketing Agency</h3>
{/* 대행사 가치축: 수주(Pitch) · 전략 품질(Strategy) · 포트폴리오 확장(Portfolio). */}
<ul className="space-y-6">
{[
'진단 리포트와 경쟁사 벤치마크로 제안서 품질 강화, 수주 경쟁력 상승',
'병원별 12개월 로드맵으로 전략 자문 일관성과 리텐션 확보',
'다수 병원 클라이언트 통합 대시보드로 운영 효율 극대화',
].map((item, i) => (
<li key={i} className="flex items-start gap-4 text-slate-700 font-medium group">
<CheckCircle2 className="w-6 h-6 text-purple-500 shrink-0 mt-1" />
<span className="leading-relaxed text-lg group-hover:text-primary-900 transition-colors">{item}</span>
</li>
))}
</ul>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,21 @@
import Hero from '../components/Hero';
import TargetAudience from '../components/TargetAudience';
import Problems from '../components/Problems';
import Solution from '../components/Solution';
import Modules from '../components/Modules';
import UseCases from '../components/UseCases';
import CTA from '../components/CTA';
export default function LandingPage() {
return (
<main>
<Hero />
<TargetAudience />
<Problems />
<Solution />
<Modules />
<UseCases />
<CTA />
</main>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const LandingPage = lazy(() => import('./pages/LandingPage'))
export const landingRoutes: RouteObject[] = [
{ index: true, element: <LandingPage /> },
]

View File

@ -0,0 +1,123 @@
/**
* Performance mock .
*
* - CHANNELS:
* - TOP_CONTENT: TOP 5
* - OVERVIEW_STATS:
* - FUNNEL_STEPS:
* - CHANNEL_TREND / TREND_CHANNELS: 4
* - DAYS / TIME_SLOTS / HEATMAP_DATA: ×
*/
import type { ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '@/shared/icons/FilledIcons';
export interface ChannelMetric {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string }>;
brandColor: string;
bgColor: string;
followers: string;
followersDelta: string;
views: string;
viewsDelta: string;
engagement: string;
engagementDelta: string;
posts: number;
score: number;
}
export interface ContentPerformance {
id: string;
title: string;
channel: string;
type: 'video' | 'blog' | 'social';
views: string;
likes: string;
comments: string;
ctr: string;
publishedAt: string;
}
export const CHANNELS: ChannelMetric[] = [
{ id: 'youtube', name: 'YouTube', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', followers: '103K', followersDelta: '+2.1K', views: '270K', viewsDelta: '+18%', engagement: '4.2%', engagementDelta: '+0.8%', posts: 12, score: 65 },
{ id: 'instagram_kr', name: 'Instagram KR', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '14K', followersDelta: '+890', views: '45K', viewsDelta: '+32%', engagement: '3.1%', engagementDelta: '+1.2%', posts: 24, score: 35 },
{ id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '68.8K', followersDelta: '+1.2K', views: '120K', viewsDelta: '+8%', engagement: '5.6%', engagementDelta: '+0.3%', posts: 18, score: 55 },
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', followers: '0', followersDelta: 'NEW', views: '0', viewsDelta: '-', engagement: '0%', engagementDelta: '-', posts: 0, score: 0 },
{ id: 'facebook', name: 'Facebook', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', followers: '341', followersDelta: '+12', views: '2.1K', viewsDelta: '-5%', engagement: '0.8%', engagementDelta: '-0.2%', posts: 6, score: 40 },
{ id: 'naver', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', followers: '-', followersDelta: '-', views: '8.2K', viewsDelta: '+45%', engagement: '2.4%', engagementDelta: '+1.1%', posts: 8, score: 72 },
];
export const TOP_CONTENT: ContentPerformance[] = [
{ id: '1', title: '한번에 성공하는 코성형, VIEW의 비결', channel: 'YouTube', type: 'video', views: '12.4K', likes: '342', comments: '28', ctr: '8.2%', publishedAt: '3일 전' },
{ id: '2', title: '안면윤곽 수술 종류와 회복기간', channel: 'Naver Blog', type: 'blog', views: '3.2K', likes: '-', comments: '12', ctr: '12.5%', publishedAt: '5일 전' },
{ id: '3', title: 'Reel: 윤곽 전후 변화', channel: 'Instagram KR', type: 'social', views: '8.7K', likes: '567', comments: '45', ctr: '6.1%', publishedAt: '2일 전' },
{ id: '4', title: 'Shorts: 사각턱 축소 과정', channel: 'YouTube', type: 'video', views: '5.1K', likes: '189', comments: '15', ctr: '4.8%', publishedAt: '4일 전' },
{ id: '5', title: '코성형 가이드: 내 얼굴에 맞는 코', channel: 'Naver Blog', type: 'blog', views: '2.8K', likes: '-', comments: '8', ctr: '15.2%', publishedAt: '6일 전' },
];
export const OVERVIEW_STATS = [
{ label: '총 노출', value: '445K', delta: '+24%', positive: true },
{ label: '총 조회', value: '89.2K', delta: '+18%', positive: true },
{ label: '평균 참여율', value: '3.8%', delta: '+0.6%', positive: true },
{ label: '콘텐츠 발행', value: '68건', delta: '+12건', positive: true },
{ label: '신규 팔로워', value: '+4.3K', delta: '+32%', positive: true },
{ label: '전환 (상담)', value: '47건', delta: '+15건', positive: true },
];
// ─── 퍼널 데이터 ───
export const FUNNEL_STEPS = [
{ label: '노출', labelEn: 'Impressions', value: 445000, display: '445K', color: '#6C5CE7' },
{ label: '클릭', labelEn: 'Clicks', value: 89200, display: '89.2K', color: '#7C6DD8' },
{ label: '웹사이트 유입', labelEn: 'Website Visits', value: 12400, display: '12.4K', color: '#9B8AD4' },
{ label: '상담 문의', labelEn: 'Inquiries', value: 478, display: '478', color: '#B8A9E8' },
{ label: '예약 전환', labelEn: 'Conversions', value: 47, display: '47', color: '#D5CDF5' },
];
// ─── 채널 트렌드 데이터 (4주) ───
export const CHANNEL_TREND = [
{ week: 'W1', youtube: 85, instagram: 32, naver: 18, facebook: 8 },
{ week: 'W2', youtube: 92, instagram: 41, naver: 24, facebook: 7 },
{ week: 'W3', youtube: 78, instagram: 55, naver: 31, facebook: 9 },
{ week: 'W4', youtube: 105, instagram: 68, naver: 38, facebook: 6 },
];
export const TREND_CHANNELS = [
{ key: 'youtube' as const, label: 'YouTube', color: 'rgba(155,138,212,0.35)' },
{ key: 'instagram' as const, label: 'Instagram', color: 'rgba(212,168,186,0.3)' },
{ key: 'naver' as const, label: 'Naver', color: 'rgba(160,200,180,0.3)' },
{ key: 'facebook' as const, label: 'Facebook', color: 'rgba(160,180,220,0.25)' },
];
// ─── 히트맵 데이터 (요일 × 시간대) ───
export const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
export const TIME_SLOTS = ['오전 (6-12)', '오후 (12-18)', '저녁 (18-24)', '심야 (0-6)'];
// 요일 × 시간대별 참여율 (0-10 척도)
export const HEATMAP_DATA = [
[3, 7, 8, 2], // 월
[4, 6, 9, 1], // 화
[5, 8, 7, 2], // 수
[6, 9, 8, 1], // 목
[4, 7, 10, 3], // 금
[2, 5, 6, 4], // 토
[1, 4, 5, 3], // 일
];
// ─── AI 추천 ───
export const AI_RECOMMENDATIONS = [
{ title: 'YouTube Shorts 확대', desc: 'Shorts 조회수가 Long-form 대비 3.2배 높습니다. 주 3회 이상 Shorts 업로드를 권장합니다.' },
{ title: 'Instagram Reels 시작', desc: 'KR 계정에 Reels 0개입니다. 경쟁 병원 평균 주 5개 — 즉시 시작이 필요합니다.' },
{ title: '랜딩 페이지 최적화', desc: '클릭→유입 전환율 13.9%로 업계 평균 20% 대비 낮음. CTA 버튼 위치와 페이지 속도 개선 필요.' },
];

View File

@ -0,0 +1,406 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import {
VideoFilled,
FileTextFilled,
ShareFilled,
} from '@/shared/icons/FilledIcons';
import type { ComponentType } from 'react';
import { Button } from '@/shared/ui/button';
import {
CHANNELS,
TOP_CONTENT,
OVERVIEW_STATS,
FUNNEL_STEPS,
CHANNEL_TREND,
TREND_CHANNELS,
DAYS,
TIME_SLOTS,
HEATMAP_DATA,
AI_RECOMMENDATIONS,
} from '../data/performanceMocks';
// ─── Component ───
const typeIcons: Record<string, ComponentType<{ size?: number; className?: string }>> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
};
const typeColors: Record<string, { bg: string; text: string }> = {
video: { bg: 'bg-brand-tint-purple', text: 'text-brand-purple-muted' },
blog: { bg: 'bg-brand-tint-violet', text: 'text-brand-purple-faint' },
social: { bg: 'bg-brand-earth-bg', text: 'text-brand-earth' },
};
function heatmapColor(value: number): string {
if (value >= 9) return 'bg-[#2d2640] text-white';
if (value >= 7) return 'bg-[#4a4460] text-white';
if (value >= 5) return 'bg-[#8e89a8] text-white';
if (value >= 3) return 'bg-[#c8c4d8] text-[#4a4460]';
return 'bg-[#f0eef5] text-[#8e89a8]';
}
export default function PerformancePage() {
const [period, setPeriod] = useState<'7d' | '30d' | '90d'>('30d');
const funnelMax = FUNNEL_STEPS[0].value;
const trendMax = Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook]));
return (
<div className="pt-20 min-h-screen">
{/* Header */}
<div className="bg-brand-navy py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-brand-purple-vivid/10 blur-[120px]" />
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
<div className="max-w-6xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3"> </h1>
<p className="text-purple-200/70 max-w-xl mb-8"> .</p>
<div className="flex gap-2">
{([
{ key: '7d' as const, label: '7일' },
{ key: '30d' as const, label: '30일' },
{ key: '90d' as const, label: '90일' },
]).map(p => (
<Button
key={p.key}
variant="ghost"
onClick={() => setPeriod(p.key)}
className={`px-4 py-2 h-auto rounded-full text-sm font-medium transition-all ${
period === p.key
? 'bg-white text-brand-navy hover:bg-white hover:text-brand-navy'
: 'bg-white/10 text-purple-200 hover:bg-white/20 hover:text-purple-200'
}`}
>
{p.label}
</Button>
))}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-6 py-10">
{/* Overview Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
{OVERVIEW_STATS.map((stat, i) => (
<motion.div
key={stat.label}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
<p className="text-xl font-bold text-brand-navy">{stat.value}</p>
<p className={`text-xs font-medium mt-1 ${stat.positive ? 'text-brand-purple-muted' : 'text-brand-rose'}`}>{stat.delta}</p>
</motion.div>
))}
</div>
{/* ═══ Section 1: Marketing Funnel ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-brand-navy"> </h3>
<p className="text-xs text-slate-500 mt-1"> </p>
</div>
</div>
<div className="space-y-3">
{FUNNEL_STEPS.map((step, i) => {
const widthPct = Math.max((step.value / funnelMax) * 100, 8);
const convRate = i > 0
? ((step.value / FUNNEL_STEPS[i - 1].value) * 100).toFixed(1)
: null;
return (
<motion.div
key={step.label}
className="flex items-center gap-4"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
{/* Label */}
<div className="w-24 shrink-0 text-right">
<p className="text-sm font-medium text-brand-navy">{step.label}</p>
<p className="text-xs text-slate-400">{step.labelEn}</p>
</div>
{/* Bar */}
<div className="flex-1 relative">
<motion.div
className="h-10 rounded-xl flex items-center px-4"
style={{ backgroundColor: step.color }}
initial={{ width: 0 }}
animate={{ width: `${widthPct}%` }}
transition={{ duration: 0.7, delay: i * 0.12 }}
>
<span className="text-sm font-bold text-white whitespace-nowrap">{step.display}</span>
</motion.div>
</div>
{/* Conversion Rate */}
<div className="w-16 shrink-0 text-right">
{convRate ? (
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
parseFloat(convRate) >= 10
? 'bg-brand-tint-purple text-brand-purple-muted'
: 'bg-brand-earth-bg text-brand-earth'
}`}>
{convRate}%
</span>
) : (
<span className="text-xs text-slate-300"></span>
)}
</div>
</motion.div>
);
})}
</div>
{/* Funnel insight */}
<div className="mt-6 p-4 rounded-xl bg-brand-earth-bg border border-brand-earth-soft">
<p className="text-sm text-brand-earth">
<span className="font-semibold"> :</span> <span className="font-bold">13.9%</span> . 20% .
</p>
</div>
</div>
{/* ═══ Section 2: Channel Trend (Stacked Bar) ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-brand-navy"> </h3>
<p className="text-xs text-slate-500 mt-1"> (: K)</p>
</div>
{/* Legend */}
<div className="flex gap-3">
{TREND_CHANNELS.map(ch => (
<div key={ch.key} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-xs text-slate-500">{ch.label}</span>
</div>
))}
</div>
</div>
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
{CHANNEL_TREND.map((week, wi) => {
const total = week.youtube + week.instagram + week.naver + week.facebook;
return (
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
{/* Total label above bar */}
<p className="text-xs text-slate-500 font-medium">{total}K</p>
{/* Stacked bar */}
<div className="w-full flex flex-col-reverse items-stretch rounded-xl overflow-hidden" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
{TREND_CHANNELS.map(ch => {
const val = week[ch.key];
const segH = (val / total) * 100;
return (
<motion.div
key={ch.key}
className="w-full relative group"
style={{ height: `${segH}%`, backgroundColor: ch.color }}
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-brand-navy text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
);
})}
</div>
{/* Week label */}
<p className="text-xs font-medium text-slate-600">{week.week}</p>
</div>
);
})}
</div>
{/* Trend insight */}
<div className="mt-6 p-4 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender">
<p className="text-sm text-brand-purple-muted">
<span className="font-semibold"> :</span> Instagram <span className="font-bold">+112%</span> (W1W4). Naver Blog <span className="font-bold">+111%</span> . YouTube .
</p>
</div>
</div>
{/* ═══ Section 3: Day × Time Heatmap ═══ */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-brand-navy"> </h3>
<p className="text-xs text-slate-500 mt-1">× </p>
</div>
{/* Scale legend */}
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400"></span>
{[1, 3, 5, 7, 9].map(v => (
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
))}
<span className="text-xs text-slate-400"></span>
</div>
</div>
{/* Heatmap grid */}
<div className="overflow-x-auto">
<div className="min-w-[500px]">
{/* Time slot headers */}
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
<div />
{TIME_SLOTS.map(slot => (
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, di) => (
<motion.div
key={day}
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: di * 0.05 }}
>
<div className="flex items-center justify-center text-sm font-medium text-brand-navy">{day}</div>
{HEATMAP_DATA[di].map((val, ti) => (
<div
key={ti}
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-105 cursor-default ${heatmapColor(val)}`}
>
{val > 0 ? `${val * 10}%` : '-'}
</div>
))}
</motion.div>
))}
</div>
</div>
{/* Heatmap insight */}
<div className="mt-6 p-4 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender">
<p className="text-sm text-brand-purple-muted">
<span className="font-semibold"> :</span> <span className="font-bold"> (18-24)</span> . (12-18) . .
</p>
</div>
</div>
{/* Channel Performance Grid */}
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
{CHANNELS.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-brand-navy">{ch.name}</h4>
<p className="text-xs text-slate-400">{ch.posts} </p>
</div>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
ch.score >= 70 ? 'bg-brand-tint-purple text-brand-purple-muted' :
ch.score >= 40 ? 'bg-brand-earth-bg text-brand-earth' :
ch.score > 0 ? 'bg-brand-rose-bg text-brand-rose' :
'bg-slate-50 text-slate-400'
}`}>
{ch.score || '-'}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
</div>
</motion.div>
);
})}
</div>
{/* Top Content */}
<h3 className="font-serif font-bold text-xl text-brand-navy mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-brand-navy text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right">CTR</span>
<span className="text-right"></span>
</div>
{TOP_CONTENT.map((content, i) => {
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
const colors = typeColors[content.type] ?? typeColors.blog;
return (
<motion.div
key={content.id}
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
} border-b border-slate-50 last:border-0`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
<TypeIcon size={14} className={colors.text} />
</div>
<span className="text-sm text-brand-navy truncate">{content.title}</span>
</div>
<span className="text-xs text-slate-500">{content.channel}</span>
<span className="text-sm font-medium text-brand-navy text-right">{content.views}</span>
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
<span className={`text-sm font-medium text-right ${
parseFloat(content.ctr) >= 10 ? 'text-brand-purple-muted' : 'text-slate-600'
}`}>{content.ctr}</span>
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
</motion.div>
);
})}
</div>
{/* AI Recommendations */}
<div className="bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky rounded-2xl p-8">
<h3 className="font-serif font-bold text-xl text-brand-purple-deep mb-4">AI </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{AI_RECOMMENDATIONS.map((rec, i) => (
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
<h4 className="font-semibold text-brand-purple-deep mb-2">{rec.title}</h4>
<p className="text-sm text-brand-purple-deep/60">{rec.desc}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
function MetricCell({ label, value, delta }: { label: string; value: string; delta: string }) {
const isPositive = delta.startsWith('+');
const isNew = delta === 'NEW' || delta === '-';
return (
<div className="text-center">
<p className="text-xs text-slate-400 mb-1">{label}</p>
<p className="text-sm font-semibold text-brand-navy">{value}</p>
<p className={`text-xs font-medium ${
isNew ? 'text-slate-400' : isPositive ? 'text-brand-purple-muted' : 'text-brand-rose'
}`}>{delta}</p>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const PerformancePage = lazy(() => import('./pages/PerformancePage'))
export const performanceRoutes: RouteObject[] = [
{ path: 'performance', element: <PerformancePage /> },
]

View File

@ -0,0 +1,202 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { YoutubeFilled } from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import AssetDetailModal from './AssetDetailModal';
import type { AssetCollectionData, AssetCard, YouTubeRepurposeItem, AssetSource, AssetStatus, AssetType } from '@/features/plan/types/plan';
type ModalAsset =
| { kind: 'asset'; data: AssetCard }
| { kind: 'youtube'; data: YouTubeRepurposeItem };
interface AssetCollectionProps {
data: AssetCollectionData;
}
const filterTabs = [
{ key: 'all', label: '전체' },
{ key: 'homepage', label: '홈페이지' },
{ key: 'naver_place', label: '네이버' },
{ key: 'blog', label: '블로그' },
{ key: 'social', label: '소셜미디어' },
{ key: 'youtube', label: 'YouTube' },
] as const;
type FilterKey = (typeof filterTabs)[number]['key'];
const sourceBadgeColors: Record<AssetSource, string> = {
homepage: 'bg-slate-100 text-slate-700',
naver_place: 'bg-brand-tint-purple text-brand-purple-muted',
blog: 'bg-brand-tint-violet text-brand-purple-faint',
social: 'bg-brand-rose-bg text-brand-rose',
youtube: 'bg-brand-rose-bg text-brand-rose',
};
const typeBadgeColors: Record<AssetType, string> = {
photo: 'bg-brand-tint-violet text-brand-purple-faint',
video: 'bg-brand-tint-purple text-brand-purple-muted',
text: 'bg-brand-earth-bg text-brand-earth',
};
const statusConfig: Record<AssetStatus, { className: string; label: string }> = {
collected: { className: 'bg-brand-tint-purple text-brand-purple-muted', label: '수집완료' },
pending: { className: 'bg-brand-earth-bg text-brand-earth', label: '수집 대기' },
needs_creation: { className: 'bg-brand-rose-bg text-brand-rose', label: '제작 필요' },
};
function formatViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}
export default function AssetCollection({ data }: AssetCollectionProps) {
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const [selectedAsset, setSelectedAsset] = useState<ModalAsset | null>(null);
const filteredAssets =
activeFilter === 'all'
? data.assets
: data.assets.filter((a) => a.source === activeFilter);
return (
<SectionWrapper
id="asset-collection"
title="Asset Collection"
subtitle="에셋 수집 & 리퍼포징 소스"
>
{/* Source Filter Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{filterTabs.map((tab) => (
<Button
key={tab.key}
onClick={() => setActiveFilter(tab.key)}
variant={activeFilter === tab.key ? 'default' : 'outline'}
className={`rounded-full px-4 py-2 h-auto text-sm font-medium ${
activeFilter === tab.key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-lg hover:from-brand-purple hover:to-brand-purple-deep'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</Button>
))}
</div>
{/* Asset Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
{filteredAssets.map((asset, i) => {
const statusInfo = statusConfig[asset.status];
return (
<motion.div
key={asset.id}
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-5 cursor-pointer hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-brand-tint-lavender transition-all"
onClick={() => setSelectedAsset({ kind: 'asset', data: asset })}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.05 }}
>
{/* Top badges row */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${sourceBadgeColors[asset.source]}`}
>
{asset.sourceLabel}
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${typeBadgeColors[asset.type]}`}
>
{asset.type}
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ml-auto ${statusInfo.className}`}
>
{statusInfo.label}
</span>
</div>
{/* Title & Description */}
<h4 className="font-semibold text-brand-navy mb-1">{asset.title}</h4>
<p className="text-sm text-slate-600 mb-3">{asset.description}</p>
{/* Repurposing suggestions */}
{asset.repurposingSuggestions.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">
Repurposing &rarr;
</p>
<div className="flex flex-wrap gap-2">
{asset.repurposingSuggestions.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-brand-tint-purple text-brand-purple-muted text-xs px-2 py-1"
>
{suggestion}
</span>
))}
</div>
</div>
)}
</motion.div>
);
})}
</div>
{/* YouTube Repurpose Section */}
{data.youtubeRepurpose.length > 0 && (
<div>
<h3 className="font-serif text-2xl font-bold text-brand-navy mb-4">
YouTube Top Videos for Repurposing
</h3>
<div className="flex overflow-x-auto gap-4 pb-4 scrollbar-hide" style={{ scrollbarWidth: 'none' }}>
{data.youtubeRepurpose.map((video, i) => (
<motion.div
key={video.title}
className="min-w-[280px] rounded-2xl border border-slate-100 bg-white shadow-sm p-5 shrink-0 cursor-pointer hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-brand-tint-lavender transition-all"
onClick={() => setSelectedAsset({ kind: 'youtube', data: video })}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="flex items-start gap-2 mb-3">
<YoutubeFilled size={18} className="text-brand-rose-mid shrink-0 mt-0.5" />
<h4 className="font-semibold text-sm text-brand-navy">{video.title}</h4>
</div>
<div className="flex items-center gap-2 mb-3">
<span className="rounded-full bg-slate-100 text-slate-700 px-3 py-1 text-xs font-medium">
{formatViews(video.views)} views
</span>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
video.type === 'Short'
? 'bg-brand-tint-purple text-brand-purple-muted'
: 'bg-brand-tint-violet text-brand-purple-faint'
}`}
>
{video.type}
</span>
</div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">
Repurpose As:
</p>
<div className="flex flex-wrap gap-2">
{video.repurposeAs.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-brand-tint-purple text-brand-purple-muted text-xs px-2 py-1"
>
{suggestion}
</span>
))}
</div>
</motion.div>
))}
</div>
</div>
)}
<AssetDetailModal
asset={selectedAsset}
onClose={() => setSelectedAsset(null)}
/>
</SectionWrapper>
);
}

View File

@ -0,0 +1,223 @@
import { useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
GlobeFilled,
VideoFilled,
FileTextFilled,
ShareFilled,
RefreshFilled,
BoltFilled,
} from '@/shared/icons/FilledIcons';
import { Button } from '@/shared/ui/button';
import type { AssetCard, YouTubeRepurposeItem, AssetSource, AssetType, AssetStatus } from '@/features/plan/types/plan';
type ModalAsset =
| { kind: 'asset'; data: AssetCard }
| { kind: 'youtube'; data: YouTubeRepurposeItem };
interface AssetDetailModalProps {
asset: ModalAsset | null;
onClose: () => void;
}
const sourceIcon: Record<AssetSource, typeof GlobeFilled> = {
homepage: GlobeFilled,
naver_place: GlobeFilled,
blog: FileTextFilled,
social: ShareFilled,
youtube: YoutubeFilled,
};
const typeLabels: Record<AssetType, string> = {
photo: '사진',
video: '영상',
text: '텍스트',
};
const statusConfig: Record<AssetStatus, { bg: string; text: string; border: string; label: string }> = {
collected: { bg: 'bg-brand-tint-purple', text: 'text-brand-purple-muted', border: 'border-brand-tint-lavender', label: '수집 완료' },
pending: { bg: 'bg-brand-earth-bg', text: 'text-brand-earth', border: 'border-brand-earth-soft', label: '수집 대기' },
needs_creation: { bg: 'bg-brand-rose-bg', text: 'text-brand-rose', border: 'border-brand-rose-soft', label: '제작 필요' },
};
function formatViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}
export default function AssetDetailModal({ asset, onClose }: AssetDetailModalProps) {
// Escape 키로 닫기
useEffect(() => {
if (!asset) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [asset, onClose]);
return (
<AnimatePresence>
{asset && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Modal Panel */}
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"
initial={{ opacity: 0, scale: 0.95, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<div
className="bg-white rounded-3xl shadow-2xl w-full max-w-lg pointer-events-auto overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header bar */}
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center gap-3">
{asset.kind === 'asset' ? (
<>
{(() => {
const Icon = sourceIcon[asset.data.source];
const status = statusConfig[asset.data.status];
return (
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${status.bg} border ${status.border}`}>
<Icon size={16} className={status.text} />
</div>
);
})()}
<div>
<p className="font-bold text-brand-navy text-sm">{asset.data.title}</p>
<p className="text-xs text-slate-500">{asset.data.sourceLabel} · {typeLabels[asset.data.type]}</p>
</div>
</>
) : (
<>
<div className="w-9 h-9 rounded-xl bg-brand-rose-bg border border-brand-rose-soft flex items-center justify-center">
<YoutubeFilled size={16} className="text-brand-rose-mid" />
</div>
<div>
<p className="font-bold text-brand-navy text-sm">{asset.data.title}</p>
<p className="text-xs text-slate-500">YouTube · {asset.data.type === 'Short' ? 'Shorts' : 'Long-form'} · {formatViews(asset.data.views)} views</p>
</div>
</>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onClose}
className="rounded-full bg-slate-100 hover:bg-slate-200 text-slate-500"
>
<svg className="w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5 max-h-[60vh] overflow-y-auto">
{asset.kind === 'asset' && (
<>
{/* Status + type badges */}
<div className="flex flex-wrap gap-2">
{(() => {
const s = statusConfig[asset.data.status];
return (
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${s.bg} ${s.text} ${s.border}`}>
{s.label}
</span>
);
})()}
<span className="text-xs font-medium px-3 py-1 rounded-full bg-slate-100 text-slate-600">
{typeLabels[asset.data.type]}
</span>
</div>
{/* Description */}
<div>
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-1.5"></p>
<p className="text-sm text-slate-700 leading-relaxed">{asset.data.description}</p>
</div>
{/* Repurposing suggestions */}
{asset.data.repurposingSuggestions.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<RefreshFilled size={13} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest"> </p>
</div>
<div className="flex flex-wrap gap-2">
{asset.data.repurposingSuggestions.map((s, i) => (
<span key={i} className="text-xs px-3 py-1.5 rounded-full bg-brand-tint-purple text-brand-purple-muted border border-brand-tint-lavender">
{s}
</span>
))}
</div>
</div>
)}
</>
)}
{asset.kind === 'youtube' && (
<>
{/* Stats */}
<div className="flex gap-3">
<div className="flex-1 rounded-xl bg-slate-50 border border-slate-100 p-3 text-center">
<p className="text-xl font-bold text-brand-navy">{formatViews(asset.data.views)}</p>
<p className="text-xs text-slate-500 mt-0.5"></p>
</div>
<div className="flex-1 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender p-3 text-center">
<p className="text-xl font-bold text-brand-purple-muted">{asset.data.type === 'Short' ? 'Shorts' : 'Long'}</p>
<p className="text-xs text-brand-purple-vivid mt-0.5"></p>
</div>
</div>
{/* Repurpose targets */}
{asset.data.repurposeAs.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<BoltFilled size={13} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest"> </p>
</div>
<div className="flex flex-wrap gap-2">
{asset.data.repurposeAs.map((s, i) => (
<span key={i} className="text-xs px-3 py-1.5 rounded-full bg-brand-tint-purple text-brand-purple-muted border border-brand-tint-lavender">
{s}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
{/* Footer CTA */}
<div className="px-6 pb-5 pt-3 border-t border-slate-50">
<Button
type="button"
size="lg"
onClick={onClose}
className="w-full py-2.5 h-auto rounded-xl bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-sm font-semibold hover:from-brand-purple hover:to-brand-purple-deep hover:opacity-90 transition-opacity"
>
</Button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,496 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import {
CheckFilled,
CrossFilled,
WarningFilled,
} from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import {
tabItems,
type TabKey,
getChannelIcon,
statusColor,
statusLabel,
} from '../data/brandingGuideConstants';
import type { BrandGuide, ColorSwatch } from '@/features/plan/types/plan';
import type { BrandInconsistency } from '@/features/report/types/report';
interface BrandingGuideProps {
data: BrandGuide;
}
/* ─── 색상 스와치 편집 팝오버 ─── */
function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: (newHex: string) => void }) {
const [open, setOpen] = useState(false);
const [hexInput, setHexInput] = useState(swatch.hex);
const popoverRef = useRef<HTMLDivElement>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
// 외부 클릭 시 닫기
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
// 외부에서 스와치가 변경될 때 hex 입력 동기화
useEffect(() => { setHexInput(swatch.hex); }, [swatch.hex]);
const applyHex = (value: string) => {
const normalized = value.startsWith('#') ? value : `#${value}`;
if (/^#[0-9A-Fa-f]{6}$/.test(normalized)) {
onUpdate(normalized.toUpperCase());
setHexInput(normalized.toUpperCase());
}
};
return (
<div ref={popoverRef} className="relative rounded-2xl border border-slate-100 overflow-visible shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
{/* Swatch — click to open */}
<button
type="button"
className="w-full h-20 rounded-t-2xl cursor-pointer group relative overflow-hidden"
style={{ backgroundColor: swatch.hex }}
onClick={() => { setOpen((p) => !p); setHexInput(swatch.hex); }}
title="색상 편집"
>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<svg className="w-5 h-5 text-white opacity-0 group-hover:opacity-80 transition-opacity drop-shadow" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536M9 11l6-6 3 3-6 6H9v-3z" />
</svg>
</div>
</button>
<div className="p-3">
<p className="font-mono text-sm text-slate-700">{swatch.hex}</p>
<p className="font-medium text-sm text-brand-navy">{swatch.name}</p>
<p className="text-xs text-slate-500">{swatch.usage}</p>
</div>
{/* Edit Popover */}
{open && (
<div className="absolute top-full left-0 mt-2 z-50 bg-white rounded-2xl shadow-[4px_6px_16px_rgba(0,0,0,0.12)] border border-slate-100 p-4 w-52">
{/* Native color wheel */}
<div className="flex items-center gap-3 mb-3">
<input
ref={colorInputRef}
type="color"
value={swatch.hex}
onChange={(e) => { onUpdate(e.target.value.toUpperCase()); setHexInput(e.target.value.toUpperCase()); }}
className="w-10 h-10 rounded-xl border border-slate-200 cursor-pointer p-0.5"
/>
<span className="text-xs text-slate-500"> </span>
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">#</span>
<Input
type="text"
value={hexInput.replace('#', '')}
onChange={(e) => setHexInput(`#${e.target.value}`)}
onBlur={(e) => applyHex(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyHex(hexInput); }}
maxLength={6}
placeholder="6C5CE7"
className="flex-1 font-mono text-sm border-slate-200 rounded-lg px-2 py-1.5 h-auto text-brand-navy focus-visible:ring-brand-purple-vivid/30 focus-visible:border-brand-purple-vivid"
/>
</div>
<Button
type="button"
onClick={() => setOpen(false)}
className="mt-3 w-full text-xs py-1.5 h-auto rounded-xl bg-brand-tint-purple text-brand-purple-muted font-medium hover:bg-[#EBE6FF]"
>
</Button>
</div>
)}
</div>
);
}
/* ─── 비주얼 아이덴티티 탭 ─── */
function VisualIdentityTab({ data }: { data: BrandGuide }) {
const [colors, setColors] = useState<ColorSwatch[]>(data.colors);
const handleColorUpdate = (idx: number, newHex: string) => {
setColors((prev) => prev.map((c, i) => i === idx ? { ...c, hex: newHex } : c));
};
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-8"
>
{/* Color Palette */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Color Palette</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{colors.map((swatch: ColorSwatch, idx: number) => (
<ColorSwatchCard
key={swatch.hex + idx}
swatch={swatch}
onUpdate={(newHex) => handleColorUpdate(idx, newHex)}
/>
))}
</div>
</div>
{/* Typography */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Typography</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{data.fonts.map((spec) => (
<div
key={`${spec.family}-${spec.weight}`}
className="rounded-2xl border border-slate-100 p-5"
>
<p className="text-sm text-slate-500 uppercase tracking-wide mb-2">
{spec.family}
</p>
<p
className={`mb-3 text-brand-navy ${
spec.weight.toLowerCase().includes('bold')
? 'text-2xl font-bold'
: 'text-lg'
}`}
style={{ fontFamily: spec.family }}
>
{spec.sampleText}
</p>
<p className="text-xs text-slate-500">
<span className="font-medium text-slate-700">{spec.weight}</span> &middot;{' '}
{spec.usage}
</p>
</div>
))}
</div>
</div>
{/* Logo Rules — DO / DON'T split columns */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Logo Rules</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* DO Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CheckFilled size={18} className="text-brand-purple-soft" />
<span className="font-semibold text-brand-purple-muted text-sm uppercase tracking-widest">DO</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-tint-lavender bg-brand-tint-purple/40"
>
<div className="flex items-start gap-3">
<CheckFilled size={16} className="text-brand-purple-soft shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* DON'T Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CrossFilled size={18} className="text-brand-rose-mid" />
<span className="font-semibold text-brand-rose text-sm uppercase tracking-widest">DON&apos;T</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => !r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-brand-rose-soft bg-brand-rose-bg/40"
>
<div className="flex items-start gap-3">
<CrossFilled size={16} className="text-brand-rose-mid shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-brand-navy text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
);
}
/* ─── 톤 & 보이스 탭 ─── */
function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="space-y-6"
>
{/* Personality */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Personality</h3>
<div className="flex flex-wrap gap-2">
{tone.personality.map((trait) => (
<span
key={trait}
className="bg-gradient-to-r from-brand-purple/10 to-brand-purple-deep/10 text-brand-purple border border-purple-200 rounded-full px-4 py-2 font-medium text-sm"
>
{trait}
</span>
))}
</div>
</div>
{/* Communication Style */}
<div>
<h3 className="font-serif font-bold text-2xl text-brand-navy mb-4">Communication Style</h3>
<div className="rounded-2xl bg-slate-50 p-6">
<p className="text-base leading-relaxed text-slate-700">
{tone.communicationStyle}
</p>
</div>
</div>
{/* DO / DON'T */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-brand-purple-muted mb-3 flex items-center gap-2">
<CheckFilled size={16} className="text-brand-purple-soft" /> DO
</h4>
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-purple-soft bg-brand-tint-purple/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
</div>
<div>
<h4 className="font-semibold text-brand-rose mb-3 flex items-center gap-2">
<CrossFilled size={16} className="text-brand-rose-mid" /> DON&apos;T
</h4>
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
<div
key={i}
className="border-l-4 border-brand-rose-mid bg-brand-rose-bg/30 p-4 rounded-r-lg"
>
<p className="text-sm text-slate-700">{example}</p>
</div>
))}
</div>
</div>
</div>
</motion.div>
);
}
/* ─── 채널 규칙 탭 ─── */
function ChannelRulesTab({ channels }: { channels: BrandGuide['channelBranding'] }) {
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((ch) => {
const Icon = getChannelIcon(ch.icon);
return (
<div key={ch.channel} className="rounded-2xl border border-slate-100 p-5">
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-brand-purple-vivid/10 to-brand-purple/10 flex items-center justify-center">
<Icon size={18} className="text-brand-purple-vivid" />
</div>
<p className="font-bold text-brand-navy">{ch.channel}</p>
<span
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border ${
statusColor[ch.currentStatus]
}`}
>
{statusLabel[ch.currentStatus]}
</span>
</div>
{/* Specs */}
<div className="space-y-3 text-sm">
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Profile Photo</p>
<p className="text-slate-700 font-medium">{ch.profilePhoto}</p>
</div>
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Banner Spec</p>
<p className="text-slate-700 font-medium">{ch.bannerSpec}</p>
</div>
<div>
<p className="text-slate-500 text-xs uppercase tracking-wide mb-1">Bio Template</p>
<div className="bg-slate-50 rounded-xl p-3">
<p className="font-mono text-xs text-slate-600 whitespace-pre-wrap">
{ch.bioTemplate}
</p>
</div>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
);
}
/* ─── 브랜드 일관성 탭 (아코디언) ─── */
function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandInconsistency[] }) {
const [expanded, setExpanded] = useState<number | null>(0);
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<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 */}
<Button
type="button"
variant="ghost"
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 h-auto text-left hover:bg-slate-50/50 rounded-none"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-brand-navy 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-brand-navy">{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 */}
{expanded === i && (
<div className="px-5 pb-5 border-t border-slate-100">
<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-brand-tint-purple/60' : 'bg-brand-rose-bg/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">
{v.channel}
</span>
<span
className={`flex-1 text-right ${
v.isCorrect ? 'text-brand-purple-muted' : 'text-brand-rose'
}`}
>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect ? (
<CheckFilled size={15} className="text-brand-purple-soft" />
) : (
<CrossFilled size={15} className="text-brand-rose-mid" />
)}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-brand-earth-bg border border-brand-earth-soft p-4 mb-3">
<p className="text-xs font-semibold text-brand-earth uppercase tracking-wide mb-1 flex items-center gap-1">
<WarningFilled size={12} className="text-[#D4A872]" />
Impact
</p>
<p className="text-sm text-brand-earth">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-brand-tint-purple border border-brand-tint-lavender p-4">
<p className="text-xs font-semibold text-brand-purple-muted uppercase tracking-wide mb-1 flex items-center gap-1">
<CheckFilled size={12} className="text-brand-purple-soft" />
Recommendation
</p>
<p className="text-sm text-brand-purple-muted">{item.recommendation}</p>
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
);
}
/* ─── 메인 컴포넌트 ─── */
export default function BrandingGuide({ data }: BrandingGuideProps) {
const [activeTab, setActiveTab] = useState<TabKey>('visual');
return (
<SectionWrapper
id="branding-guide"
title="Branding Guide"
subtitle="브랜딩 가이드 빌드"
>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<Button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
variant={activeTab === tab.key ? 'default' : 'outline'}
className={`rounded-full px-4 py-2 h-auto text-sm font-medium ${
activeTab === tab.key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-lg hover:from-brand-purple hover:to-brand-purple-deep'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</Button>
))}
</div>
{activeTab === 'visual' && <VisualIdentityTab data={data} />}
{activeTab === 'tone' && <ToneVoiceTab tone={data.toneOfVoice} />}
{activeTab === 'channels' && <ChannelRulesTab channels={data.channelBranding} />}
{activeTab === 'consistency' && (
<BrandConsistencyTab inconsistencies={data.brandInconsistencies} />
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,121 @@
import { type ComponentType } from 'react';
import { motion } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
CalendarFilled,
TiktokFilled,
} from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import type { ChannelStrategyCard } from '@/features/plan/types/plan';
interface ChannelStrategyProps {
channels: ChannelStrategyCard[];
}
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
tiktok: TiktokFilled,
};
function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
const priorityStyle: Record<string, string> = {
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border border-[#F5D5DC] shadow-[2px_3px_8px_rgba(212,136,154,0.15)]',
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border border-[#F5E0C5] shadow-[2px_3px_8px_rgba(212,168,114,0.15)]',
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border border-[#D5CDF5] shadow-[2px_3px_8px_rgba(155,138,212,0.15)]',
};
export default function ChannelStrategy({ channels }: ChannelStrategyProps) {
return (
<SectionWrapper
id="channel-strategy"
title="Channel Strategy"
subtitle="채널별 커뮤니케이션 전략"
dark
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{channels.map((ch, index) => {
const Icon = getChannelIcon(ch.icon);
return (
<motion.div
key={ch.channelId}
className="bg-white rounded-2xl p-6 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
{/* Header */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center">
<Icon size={20} className="text-[#9B8AD4]" />
</div>
<h4 className="text-lg font-bold text-[#0A1128] flex-1">{ch.channelName}</h4>
<span
className={`text-xs font-semibold px-3 py-1 rounded-full ${
priorityStyle[ch.priority] ?? 'bg-slate-100 text-slate-600'
}`}
>
{ch.priority}
</span>
</div>
{/* Current → Target */}
<div className="flex items-center gap-2 mb-4 flex-wrap">
<span className="bg-[#FFF0F0] text-[#7C3A4B] rounded-full px-3 py-1 text-xs font-medium border border-[#F5D5DC] shadow-[2px_3px_6px_rgba(212,136,154,0.12)]">
{ch.currentStatus}
</span>
<span className="text-slate-400 text-sm">&rarr;</span>
<span className="bg-[#F3F0FF] text-[#4A3A7C] rounded-full px-3 py-1 text-xs font-medium border border-[#D5CDF5] shadow-[2px_3px_6px_rgba(155,138,212,0.12)]">
{ch.targetGoal}
</span>
</div>
{/* Content Types */}
<div className="flex flex-wrap gap-2 mb-4">
{ch.contentTypes.map((type) => (
<span
key={type}
className="bg-slate-50 border border-slate-100 rounded-full px-3 py-1 text-xs text-slate-600 font-medium shadow-[2px_3px_6px_rgba(0,0,0,0.04)]"
>
{type}
</span>
))}
</div>
{/* Posting Frequency */}
<div className="flex items-center gap-2 mb-3">
<CalendarFilled size={14} className="text-[#9B8AD4] shrink-0" />
<p className="text-sm text-slate-600">{ch.postingFrequency}</p>
</div>
{/* Tone */}
<p className="text-sm italic text-[#6C5CE7]/70 mb-4">{ch.tone}</p>
{/* Format Guidelines */}
<ul className="space-y-2">
{ch.formatGuidelines.map((guideline, i) => (
<li key={i} className="flex items-start gap-2">
<span className="shrink-0 w-2 h-2 rounded-full bg-[#6C5CE7] mt-2" />
<span className="text-sm text-slate-700">{guideline}</span>
</li>
))}
</ul>
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,432 @@
import React, { useState, useCallback } from 'react';
import { exportCalendarToICS } from '@/features/plan/lib/calendarExport';
import { GlobeFilled, CalendarFilled } from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import EditEntryModal from './EditEntryModal';
import {
contentTypeColors,
contentTypeIcons,
channelIconMap,
statusDotColors,
dayHeaders,
} from '../data/contentCalendarConstants';
import type { CalendarData, ContentCategory, CalendarEntry } from '@/features/plan/types/plan';
interface ContentCalendarProps {
data: CalendarData;
planId?: string;
onEntryUpdate?: (entryId: string, updates: Partial<CalendarEntry>) => void;
}
export default function ContentCalendar({ data, planId, onEntryUpdate }: ContentCalendarProps) {
// 마운트 시 id 없는 항목에 자동 id 부여 (드래그 식별용)
const [weeks, setWeeks] = useState(() =>
data.weeks.map((week) => ({
...week,
entries: week.entries.map((e, i) => ({
...e,
id: e.id ?? `auto-${week.weekNumber}-${i}`,
})),
})),
);
const [editingEntry, setEditingEntry] = useState<CalendarEntry | null>(null);
const [creatingForWeek, setCreatingForWeek] = useState<number | null>(null);
const [filterType, setFilterType] = useState<ContentCategory | null>(null);
const [viewMode, setViewMode] = useState<'weekly' | 'monthly'>('weekly');
// DnD 상태: 어떤 항목을 어느 주차에서 드래그 중인지 추적
const [draggedEntry, setDraggedEntry] = useState<{ entry: CalendarEntry; weekNumber: number } | null>(null);
const [dropTargetDay, setDropTargetDay] = useState<{ weekNumber: number; dayIdx: number } | null>(null);
const handleEntryClick = useCallback((entry: CalendarEntry) => {
setCreatingForWeek(null);
setEditingEntry(entry);
}, []);
// 특정 주차에 대해 생성 모드로 모달 열기
const handleAddEntry = useCallback((weekNumber: number) => {
setCreatingForWeek(weekNumber);
setEditingEntry({
id: '__new__',
dayOfWeek: 0,
channel: 'YouTube',
channelIcon: 'youtube',
contentType: 'video',
title: '',
status: 'draft',
});
}, []);
// 드래그 핸들러 — 같은 주차 내 다른 요일로 항목 이동
const handleDragStart = useCallback((entry: CalendarEntry, weekNumber: number) => {
setDraggedEntry({ entry, weekNumber });
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, weekNumber: number, dayIdx: number) => {
e.preventDefault(); // drop 허용을 위해 필요
setDropTargetDay({ weekNumber, dayIdx });
}, []);
const handleDragLeave = useCallback(() => {
setDropTargetDay(null);
}, []);
const handleDrop = useCallback((weekNumber: number, dayIdx: number) => {
const draggedId = draggedEntry?.entry.id;
if (!draggedEntry || !draggedId) return;
const sourceWeek = draggedEntry.weekNumber;
setWeeks((prev) =>
prev.map((week) => {
// 같은 주차 내 이동
if (sourceWeek === weekNumber && week.weekNumber === weekNumber) {
return {
...week,
entries: week.entries.map((e) =>
e.id === draggedId ? { ...e, dayOfWeek: dayIdx, isManualEdit: true } : e,
),
};
}
// 다른 주차로 이동 — 원본에서 제거
if (week.weekNumber === sourceWeek) {
return { ...week, entries: week.entries.filter((e) => e.id !== draggedId) };
}
// 다른 주차로 이동 — 대상에 추가
if (week.weekNumber === weekNumber) {
return {
...week,
entries: [
...week.entries,
{ ...draggedEntry.entry, dayOfWeek: dayIdx, isManualEdit: true },
],
};
}
return week;
}),
);
setDraggedEntry(null);
setDropTargetDay(null);
}, [draggedEntry]);
// 월간 뷰 드롭: 주차는 유지하고 요일만 변경
const handleDropMonthly = useCallback((dayIdx: number) => {
const draggedId = draggedEntry?.entry.id;
if (!draggedEntry || !draggedId) return;
setWeeks((prev) =>
prev.map((week) => ({
...week,
entries: week.entries.map((e) =>
e.id === draggedId ? { ...e, dayOfWeek: dayIdx, isManualEdit: true } : e,
),
})),
);
setDraggedEntry(null);
setDropTargetDay(null);
}, [draggedEntry]);
const handleDragEnd = useCallback(() => {
setDraggedEntry(null);
setDropTargetDay(null);
}, []);
const handleSave = useCallback((updated: CalendarEntry) => {
if (updated.id === '__new__' && creatingForWeek !== null) {
// 생성 모드: 새로 생성된 id로 항목 추가
const newEntry: CalendarEntry = {
...updated,
id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
setWeeks((prev) =>
prev.map((week) =>
week.weekNumber === creatingForWeek
? { ...week, entries: [...week.entries, newEntry] }
: week
)
);
setCreatingForWeek(null);
} else {
// 편집 모드: id로 기존 항목 업데이트
setWeeks((prev) =>
prev.map((week) => ({
...week,
entries: week.entries.map((e) =>
e.id && e.id === updated.id ? updated : e
),
}))
);
if (onEntryUpdate && updated.id) {
onEntryUpdate(updated.id, updated);
}
}
setEditingEntry(null);
}, [onEntryUpdate, creatingForWeek]);
const handleClose = useCallback(() => {
setEditingEntry(null);
setCreatingForWeek(null);
}, []);
const toggleFilter = (type: ContentCategory) => {
setFilterType((prev) => (prev === type ? null : type));
};
// 월간 뷰: 모든 주차를 하나의 7일 그리드로 평탄화. 카드 드래그/드롭 지원 (주차 유지, 요일만 변경).
const renderMonthlyView = () => {
const all = weeks.flatMap((w) =>
w.entries.map((entry) => ({ entry, weekNumber: w.weekNumber })),
);
const filtered = filterType ? all.filter(({ entry }) => entry.contentType === filterType) : all;
const dayCells: { entry: CalendarEntry; weekNumber: number }[][] = Array.from({ length: 7 }, () => []);
for (const item of filtered) {
if (item.entry.dayOfWeek >= 0 && item.entry.dayOfWeek <= 6) {
dayCells[item.entry.dayOfWeek].push(item);
}
}
return (
<div className="bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
<p className="text-sm font-bold text-brand-navy mb-3"> </p>
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div key={day} className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium">
{day}
</div>
))}
{dayCells.map((cell, dayIdx) => {
const isDropTarget = dropTargetDay?.weekNumber === -1 && dropTargetDay.dayIdx === dayIdx;
return (
<div
key={dayIdx}
className={`min-h-[100px] rounded-xl p-1.5 transition-all ${
cell.length > 0 ? '' : 'border border-dashed border-slate-200'
} ${isDropTarget ? 'ring-2 ring-brand-purple-vivid/40 bg-brand-tint-purple border-brand-tint-lavender' : ''}`}
onDragOver={(e) => handleDragOver(e, -1, dayIdx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDropMonthly(dayIdx)}
>
{cell.map(({ entry, weekNumber }, entryIdx) => renderEntry(entry, entryIdx, weekNumber))}
</div>
);
})}
</div>
</div>
);
};
const renderEntry = (entry: CalendarEntry, entryIdx: number, weekNumber?: number) => {
const colors = contentTypeColors[entry.contentType];
const ContentIcon = contentTypeIcons[entry.contentType];
const ChannelIcon = channelIconMap[entry.channelIcon] ?? GlobeFilled;
const statusDot = statusDotColors[entry.status ?? 'draft'];
const isDragging = draggedEntry !== null && draggedEntry.entry === entry;
return (
<div
key={entry.id ?? entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-brand-purple-vivid/30 hover:shadow-lg shadow-sm transition-all group relative ${isDragging ? 'opacity-40' : ''}`}
draggable={weekNumber !== undefined}
onDragStart={weekNumber !== undefined ? () => handleDragStart(entry, weekNumber) : undefined}
onDragEnd={weekNumber !== undefined ? handleDragEnd : undefined}
onClick={() => handleEntryClick(entry)}
>
<div className="flex items-center gap-1 mb-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot} flex-shrink-0`} />
<ChannelIcon size={10} className={colors.text} />
<ContentIcon size={10} className={colors.text} />
</div>
<p className="text-[11px] font-medium text-brand-navy leading-tight line-clamp-2">
{entry.title}
</p>
{entry.description && (
<p className="text-[9px] text-slate-400 leading-tight mt-0.5 line-clamp-1 hidden group-hover:block">
{entry.description}
</p>
)}
</div>
);
};
return (
<SectionWrapper
id="content-calendar"
title="Content Calendar"
subtitle="콘텐츠 캘린더 (월간)"
dark
>
{/* Toolbar: View Toggle + Status Legend */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-2" data-no-print>
{/* View toggle — styled for dark section bg */}
<div className="flex bg-white/10 rounded-xl p-0.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setViewMode('weekly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg ${
viewMode === 'weekly'
? 'bg-white/20 text-white shadow-sm hover:bg-white/20 hover:text-white'
: 'text-white/50 hover:text-white/80 hover:bg-transparent'
}`}
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setViewMode('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg ${
viewMode === 'monthly'
? 'bg-white/20 text-white shadow-sm hover:bg-white/20 hover:text-white'
: 'text-white/50 hover:text-white/80 hover:bg-transparent'
}`}
>
</Button>
</div>
{/* iCal Export Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => exportCalendarToICS(weeks)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white/70 hover:text-white text-xs font-medium"
data-no-print
>
<CalendarFilled size={12} />
</Button>
{/* Status legend with design-system colors */}
<div className="flex gap-3 text-[10px] text-white/50">
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-brand-purple-soft" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#7A84D4]" />
</span>
</div>
</div>
{/* Monthly Summary — compact counter pills (click to filter) */}
<div className="flex flex-wrap gap-3 mb-8">
{data.monthlySummary.map((item, i) => {
const colors = contentTypeColors[item.type];
const Icon = contentTypeIcons[item.type];
const isActive = filterType === item.type;
return (
<Button
type="button"
variant="ghost"
key={item.type}
className={`flex items-center gap-3 px-5 py-3 h-auto rounded-2xl border cursor-pointer ${colors.bg} ${colors.border} ${colors.shadow} hover:${colors.bg} ${
isActive ? 'ring-2 ring-white/40 ring-offset-2 ring-offset-transparent' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
onClick={() => toggleFilter(item.type)}
>
<Icon size={16} className={`${colors.text} opacity-70`} />
<div className="flex flex-col items-start">
<span className={`text-2xl font-bold leading-none ${colors.text}`}>{item.count}</span>
<span className={`text-xs font-medium ${colors.text} opacity-70 mt-0.5`}>{item.label}</span>
</div>
</Button>
);
})}
{filterType && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFilterType(null)}
className="text-xs text-white/40 hover:text-white/70 hover:bg-transparent px-3 self-center"
>
×
</Button>
)}
</div>
{/* Calendar Content */}
{viewMode === 'monthly' ? (
renderMonthlyView()
) : (
/* Weekly Calendar Grid */
weeks.map((week, weekIdx) => {
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
for (const entry of week.entries) {
if (filterType && entry.contentType !== filterType) continue;
const dayIndex = entry.dayOfWeek;
if (dayIndex >= 0 && dayIndex <= 6) {
dayCells[dayIndex].push(entry);
}
}
return (
<div
key={week.weekNumber}
className="bg-white rounded-2xl p-5 mb-4 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
>
<p className="text-sm font-bold text-brand-navy mb-3">{week.label}</p>
<div className="grid grid-cols-7 gap-2">
{dayHeaders.map((day) => (
<div
key={day}
className="text-xs text-slate-400 uppercase tracking-wide text-center mb-1 font-medium"
>
{day}
</div>
))}
{dayCells.map((entries, dayIdx) => {
const isDropTarget = dropTargetDay?.weekNumber === week.weekNumber && dropTargetDay?.dayIdx === dayIdx;
return (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-1.5 transition-all ${
entries.length > 0
? ''
: 'border border-dashed border-slate-200'
} ${isDropTarget ? 'ring-2 ring-brand-purple-vivid/40 bg-brand-tint-purple border-brand-tint-lavender' : ''}`}
onDragOver={(e) => handleDragOver(e, week.weekNumber, dayIdx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(week.weekNumber, dayIdx)}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx, week.weekNumber))}
</div>
);
})}
</div>
{/* Add Entry Button */}
<Button
type="button"
variant="outline"
onClick={() => handleAddEntry(week.weekNumber)}
className="mt-3 w-full flex items-center justify-center gap-1.5 py-2 h-auto rounded-xl border-dashed border-slate-200 text-xs text-slate-400 hover:text-slate-600 hover:border-slate-300 hover:bg-slate-50/50 shadow-none"
data-no-print
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</Button>
</div>
);
})
)}
{/* Edit Modal */}
{editingEntry && (
<EditEntryModal
entry={editingEntry}
onSave={handleSave}
onClose={handleClose}
/>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,237 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import { VideoFilled } from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import type { ContentStrategyData } from '@/features/plan/types/plan';
interface ContentStrategyProps {
data: ContentStrategyData;
}
const tabItems = [
{ key: 'pillars', label: 'Content Pillars', labelKr: '콘텐츠 필러' },
{ key: 'types', label: 'Content Types', labelKr: '콘텐츠 유형' },
{ key: 'workflow', label: 'Production Workflow', labelKr: '제작 워크플로우' },
{ key: 'repurposing', label: 'Repurposing', labelKr: '콘텐츠 재활용' },
] as const;
type TabKey = (typeof tabItems)[number]['key'];
const channelColorMap: Record<string, string> = {
YouTube: 'bg-brand-rose-bg text-brand-rose',
Instagram: 'bg-brand-rose-bg text-brand-rose',
Blog: 'bg-brand-tint-violet text-brand-purple-faint',
'블로그': 'bg-brand-tint-violet text-brand-purple-faint',
'네이버블로그': 'bg-brand-tint-purple text-brand-purple-muted',
'네이버': 'bg-brand-tint-purple text-brand-purple-muted',
Facebook: 'bg-brand-tint-violet text-brand-purple-faint',
'홈페이지': 'bg-slate-100 text-slate-700',
Website: 'bg-slate-100 text-slate-700',
TikTok: 'bg-brand-tint-purple text-brand-purple-muted',
'카카오': 'bg-brand-earth-bg text-brand-earth',
};
function getChannelBadgeClass(channel: string): string {
for (const [key, value] of Object.entries(channelColorMap)) {
if (channel.toLowerCase().includes(key.toLowerCase())) return value;
}
return 'bg-slate-100 text-slate-700';
}
export default function ContentStrategy({ data }: ContentStrategyProps) {
const [activeTab, setActiveTab] = useState<TabKey>('pillars');
return (
<SectionWrapper
id="content-strategy"
title="Content Strategy"
subtitle="콘텐츠 마케팅 전략"
>
{/* Tabs */}
<div className="flex flex-wrap gap-2 mb-8">
{tabItems.map((tab) => (
<Button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
variant={activeTab === tab.key ? 'default' : 'outline'}
className={`rounded-full px-4 py-2 h-auto text-sm font-medium ${
activeTab === tab.key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-lg hover:from-brand-purple hover:to-brand-purple-deep'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{tab.label}
</Button>
))}
</div>
{/* Tab 1: Content Pillars */}
{activeTab === 'pillars' && (
<motion.div
className="grid md:grid-cols-2 gap-6"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{data.pillars.map((pillar, i) => (
<motion.div
key={pillar.title}
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-6 border-l-4"
style={{ borderLeftColor: pillar.color }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<h4 className="font-serif text-xl font-bold text-brand-navy mb-2">
{pillar.title}
</h4>
<p className="text-sm text-slate-600 mb-3">{pillar.description}</p>
<span className="inline-block rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700 mb-4">
{pillar.relatedUSP}
</span>
<ul className="space-y-2">
{pillar.exampleTopics.map((topic, j) => (
<li key={j} className="flex items-start gap-2 text-sm text-slate-700">
<span
className="shrink-0 w-2 h-2 rounded-full mt-2"
style={{ backgroundColor: pillar.color }}
/>
{topic}
</li>
))}
</ul>
</motion.div>
))}
</motion.div>
)}
{/* Tab 2: Content Types */}
{activeTab === 'types' && (
<motion.div
className="rounded-2xl overflow-hidden border border-slate-100"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div className="grid grid-cols-4 bg-brand-navy text-white">
<div className="px-6 py-4 text-sm font-semibold">Format</div>
<div className="px-6 py-4 text-sm font-semibold">Channels</div>
<div className="px-6 py-4 text-sm font-semibold">Frequency</div>
<div className="px-6 py-4 text-sm font-semibold">Purpose</div>
</div>
{data.typeMatrix.map((row, i) => (
<motion.div
key={row.format}
className={`grid grid-cols-4 ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}`}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.04 }}
>
<div className="px-6 py-4 text-sm font-medium text-brand-navy">
{row.format}
</div>
<div className="px-6 py-4 flex flex-wrap gap-2">
{row.channels.map((ch) => (
<span
key={ch}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium ${getChannelBadgeClass(ch)}`}
>
{ch}
</span>
))}
</div>
<div className="px-6 py-4 text-sm text-slate-700">{row.frequency}</div>
<div className="px-6 py-4 text-sm text-slate-600">{row.purpose}</div>
</motion.div>
))}
</motion.div>
)}
{/* Tab 3: Production Workflow */}
{activeTab === 'workflow' && (() => {
// step 수에 따라 동일 너비 grid 컬럼 매핑 (Tailwind purge 안전)
const cols = data.workflow.length;
const gridColsClass =
cols <= 3 ? 'md:grid-cols-3'
: cols === 4 ? 'md:grid-cols-4'
: cols === 5 ? 'md:grid-cols-5'
: cols === 6 ? 'md:grid-cols-6'
: cols === 7 ? 'md:grid-cols-7'
: 'md:grid-cols-8';
return (
<motion.div
className={`grid grid-cols-1 ${gridColsClass} gap-4`}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{data.workflow.map((step, i) => (
<div key={step.step} className="relative">
<motion.div
className="h-full w-full rounded-2xl border border-slate-100 bg-white shadow-sm p-5 flex flex-col"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white font-bold flex items-center justify-center mb-3 shrink-0">
{step.step}
</div>
<h4 className="font-semibold text-brand-navy mb-1">{step.name}</h4>
<p className="text-sm text-slate-600 mb-3 flex-1">{step.description}</p>
<div className="flex flex-wrap gap-2 mt-auto">
<span className="rounded-full bg-brand-tint-purple text-brand-purple-muted px-2 py-1 text-xs">
{step.owner}
</span>
<span className="rounded-full bg-slate-100 text-slate-600 px-2 py-1 text-xs">
{step.duration}
</span>
</div>
</motion.div>
{i < data.workflow.length - 1 && (
<ArrowRight
size={20}
className="text-slate-300 absolute -right-3 top-1/2 -translate-y-1/2 hidden md:block z-10"
/>
)}
</div>
))}
</motion.div>
);
})()}
{/* Tab 4: Repurposing */}
{activeTab === 'repurposing' && (
<motion.div
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
{/* Source Card */}
<div className="rounded-2xl bg-gradient-to-r from-brand-purple to-brand-purple-deep p-6 text-white mb-6">
<div className="flex items-center gap-3">
<VideoFilled size={28} className="text-white/60" />
<h4 className="font-serif text-xl font-bold">{data.repurposingSource}</h4>
</div>
</div>
{/* Connector */}
<div className="flex justify-center mb-6">
<div className="w-px h-8 bg-gradient-to-b from-brand-purple to-slate-200" />
</div>
{/* Outputs Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.repurposingOutputs.map((output, i) => (
<motion.div
key={`${output.format}-${i}`}
className="rounded-xl border border-slate-100 bg-white p-4"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<p className="font-semibold text-sm text-brand-navy mb-1">{output.format}</p>
<p className="text-xs text-slate-500 mb-1">{output.channel}</p>
<p className="text-xs text-slate-600">{output.description}</p>
</motion.div>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,248 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import { Textarea } from '@/shared/ui/textarea';
import type { CalendarEntry, ContentCategory } from '@/features/plan/types/plan';
interface EditEntryModalProps {
entry: CalendarEntry | null;
onSave: (updated: CalendarEntry) => void;
onClose: () => void;
onRegenerate?: (entry: CalendarEntry) => void;
}
const CHANNELS = [
{ id: 'YouTube', icon: 'youtube' },
{ id: 'Instagram', icon: 'instagram' },
{ id: '네이버 블로그', icon: 'blog' },
{ id: 'Facebook', icon: 'facebook' },
{ id: 'TikTok', icon: 'video' },
];
const CONTENT_TYPES: { value: ContentCategory; label: string }[] = [
{ value: 'video', label: '영상' },
{ value: 'blog', label: '블로그' },
{ value: 'social', label: '소셜' },
{ value: 'ad', label: '광고' },
];
const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
const STATUS_OPTIONS: { value: CalendarEntry['status']; label: string; color: string }[] = [
{ value: 'draft', label: '초안', color: 'bg-slate-200 text-slate-600' },
{ value: 'approved', label: '승인', color: 'bg-brand-tint-purple text-brand-purple-muted' },
{ value: 'published', label: '게시됨', color: 'bg-brand-tint-violet text-brand-purple-faint' },
];
export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: EditEntryModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [channel, setChannel] = useState('YouTube');
const [channelIcon, setChannelIcon] = useState('youtube');
const [contentType, setContentType] = useState<ContentCategory>('video');
const [dayOfWeek, setDayOfWeek] = useState(0);
const [status, setStatus] = useState<CalendarEntry['status']>('draft');
useEffect(() => {
if (!entry) return;
setTitle(entry.title || '');
setDescription(entry.description || '');
setChannel(entry.channel || 'YouTube');
setChannelIcon(entry.channelIcon || 'youtube');
setContentType(entry.contentType || 'video');
setDayOfWeek(entry.dayOfWeek);
setStatus(entry.status || 'draft');
}, [entry]);
if (!entry) return null;
const handleChannelChange = (channelId: string) => {
const ch = CHANNELS.find((c) => c.id === channelId);
if (ch) {
setChannel(ch.id);
setChannelIcon(ch.icon);
}
};
const handleSave = () => {
onSave({
...entry,
title,
description,
channel,
channelIcon,
contentType,
dayOfWeek,
status,
isManualEdit: true,
});
};
return (
<AnimatePresence>
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Modal */}
<motion.div
className="relative w-full max-w-lg bg-white text-brand-navy rounded-2xl shadow-xl overflow-hidden"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100">
<h3 className="text-lg font-bold text-brand-navy"> </h3>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={onClose}
className="text-slate-400 hover:text-slate-600 hover:bg-transparent"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-4 max-h-[60vh] overflow-y-auto">
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<Input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 h-auto border-slate-200 rounded-xl text-sm focus-visible:ring-brand-purple-vivid/20 focus-visible:border-brand-purple-vivid"
placeholder="콘텐츠 제목을 입력하세요"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"> </label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border-slate-200 rounded-xl text-sm focus-visible:ring-brand-purple-vivid/20 focus-visible:border-brand-purple-vivid resize-none"
placeholder="AI가 생성한 제작 가이드 또는 직접 메모를 입력하세요"
/>
</div>
{/* Channel + Content Type Row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={channel}
onChange={(e) => handleChannelChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-purple-vivid/20 focus:border-brand-purple-vivid outline-none bg-white"
>
{CHANNELS.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.id}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={contentType}
onChange={(e) => setContentType(e.target.value as ContentCategory)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-purple-vivid/20 focus:border-brand-purple-vivid outline-none bg-white"
>
{CONTENT_TYPES.map((ct) => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
))}
</select>
</div>
</div>
{/* Day + Status Row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<select
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-brand-purple-vivid/20 focus:border-brand-purple-vivid outline-none bg-white"
>
{DAYS.map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-600 mb-1"></label>
<div className="flex gap-2">
{STATUS_OPTIONS.map((s) => (
<Button
type="button"
variant="ghost"
key={s.value}
onClick={() => setStatus(s.value)}
className={`px-3 py-2 h-auto rounded-xl text-xs font-medium ${
status === s.value
? `${s.color} ring-2 ring-offset-1 ring-brand-purple-soft hover:${s.color}`
: 'bg-slate-50 text-slate-400 hover:bg-slate-100'
}`}
>
{s.label}
</Button>
))}
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-100 bg-slate-50/50">
{onRegenerate ? (
<Button
type="button"
variant="ghost"
onClick={() => onRegenerate(entry)}
className="flex items-center gap-1.5 px-3 py-2 h-auto text-sm text-brand-purple-vivid hover:bg-brand-tint-purple rounded-xl"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
AI
</Button>
) : (
<div />
)}
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
onClick={onClose}
className="px-4 py-2 h-auto text-sm text-slate-500 hover:text-slate-700 hover:bg-transparent"
>
</Button>
<Button
type="button"
onClick={handleSave}
className="px-5 py-2 h-auto text-sm font-medium text-white bg-brand-purple-vivid hover:bg-[#5A4BD6] rounded-xl shadow-sm"
>
</Button>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}

View File

@ -0,0 +1,276 @@
import { useState, useRef, useCallback, type DragEvent, type ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { VideoFilled, FileTextFilled } from '@/shared/icons/FilledIcons';
import { Button } from '@/shared/ui/button';
import {
type UploadCategory,
categoryConfig,
categoryBadge,
ALL_ACCEPT,
categorize,
formatSize,
uid,
} from '../data/myAssetUploadConstants';
// ─── Types ───
interface UploadedAsset {
id: string;
file: File;
category: 'image' | 'video' | 'text';
previewUrl: string | null;
name: string;
size: string;
uploadedAt: Date;
}
// ─── Component ───
export default function MyAssetUpload() {
const [assets, setAssets] = useState<UploadedAsset[]>([]);
const [activeFilter, setActiveFilter] = useState<UploadCategory>('all');
const [isDragOver, setIsDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const processFiles = useCallback((files: FileList | File[]) => {
const newAssets: UploadedAsset[] = Array.from(files).map((file) => {
const cat = categorize(file);
const previewUrl =
cat === 'image' || cat === 'video'
? URL.createObjectURL(file)
: null;
return {
id: uid(),
file,
category: cat,
previewUrl,
name: file.name,
size: formatSize(file.size),
uploadedAt: new Date(),
};
});
setAssets((prev) => [...newAssets, ...prev]);
}, []);
const handleDrop = useCallback(
(e: DragEvent) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
},
[processFiles],
);
const handleInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
processFiles(e.target.files);
e.target.value = '';
}
},
[processFiles],
);
const removeAsset = useCallback((id: string) => {
setAssets((prev) => {
const found = prev.find((a) => a.id === id);
if (found?.previewUrl) URL.revokeObjectURL(found.previewUrl);
return prev.filter((a) => a.id !== id);
});
}, []);
const filtered =
activeFilter === 'all' ? assets : assets.filter((a) => a.category === activeFilter);
const counts = {
all: assets.length,
image: assets.filter((a) => a.category === 'image').length,
video: assets.filter((a) => a.category === 'video').length,
text: assets.filter((a) => a.category === 'text').length,
};
return (
<SectionWrapper
id="my-asset-upload"
title="나의 소재"
subtitle="소재 업로드 및 관리"
>
{/* Drop Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`relative rounded-2xl border-2 border-dashed p-10 md:p-14 text-center cursor-pointer transition-all mb-8 ${
isDragOver
? 'border-brand-purple-soft bg-brand-tint-purple/60 scale-[1.01]'
: 'border-slate-200 bg-slate-50/50 hover:border-brand-tint-lavender hover:bg-brand-tint-purple/20'
}`}
>
<input
ref={inputRef}
type="file"
multiple
accept={ALL_ACCEPT}
onChange={handleInputChange}
className="hidden"
/>
{/* Upload Icon */}
<div className="flex justify-center mb-4">
<div className="w-14 h-14 rounded-2xl bg-brand-tint-purple flex items-center justify-center shadow-[3px_4px_12px_rgba(155,138,212,0.15)]">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M12 16V4M12 4L8 8M12 4L16 8"
stroke="#9B8AD4"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 14V18C4 19.1 4.9 20 6 20H18C19.1 20 20 19.1 20 18V14"
stroke="#9B8AD4"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<p className="text-brand-navy font-semibold mb-1">
</p>
<p className="text-sm text-slate-400">
Image, Video, Text (JPG, PNG, MP4, MOV, TXT, PDF, DOC )
</p>
{/* File Type Badges */}
<div className="flex justify-center gap-2 mt-4">
{(['image', 'video', 'text'] as const).map((cat) => (
<span
key={cat}
className={`rounded-full px-3 py-1 text-xs font-medium ${categoryBadge[cat]}`}
>
{cat === 'image' ? 'Image' : cat === 'video' ? 'Video' : 'Text'}
</span>
))}
</div>
</div>
{/* Filter Tabs + Count */}
{assets.length > 0 && (
<>
<div className="flex flex-wrap items-center gap-2 mb-6">
{(Object.keys(categoryConfig) as UploadCategory[]).map((key) => (
<Button
key={key}
onClick={() => setActiveFilter(key)}
variant={activeFilter === key ? 'default' : 'outline'}
className={`rounded-full px-4 py-2 h-auto text-sm font-medium ${
activeFilter === key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-md hover:from-brand-purple hover:to-brand-purple-deep'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50 shadow-[2px_3px_6px_rgba(0,0,0,0.04)]'
}`}
>
{categoryConfig[key].label}
<span className="ml-2 opacity-70">{counts[key]}</span>
</Button>
))}
</div>
{/* Uploaded Assets Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<AnimatePresence mode="popLayout">
{filtered.map((asset) => (
<motion.div
key={asset.id}
layout
className="bg-white rounded-2xl border border-slate-100 overflow-hidden shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow group"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.25 }}
>
{/* Preview Area */}
<div className="relative h-40 bg-slate-50 flex items-center justify-center overflow-hidden">
{asset.category === 'image' && asset.previewUrl && (
<img
src={asset.previewUrl}
alt={asset.name}
className="w-full h-full object-cover"
/>
)}
{asset.category === 'video' && asset.previewUrl && (
<video
src={asset.previewUrl}
className="w-full h-full object-cover"
muted
playsInline
onMouseOver={(e) => (e.target as HTMLVideoElement).play()}
onMouseOut={(e) => {
const v = e.target as HTMLVideoElement;
v.pause();
v.currentTime = 0;
}}
/>
)}
{asset.category === 'text' && (
<div className="flex flex-col items-center gap-2">
<FileTextFilled size={36} className="text-[#D4A872]" />
<span className="text-xs text-slate-400 font-medium">
{asset.name.split('.').pop()?.toUpperCase()}
</span>
</div>
)}
{/* Remove Button */}
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => removeAsset(asset.id)}
className="absolute top-2 right-2 w-7 h-7 size-7 rounded-full bg-white/90 backdrop-blur-sm border border-slate-100 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-sm hover:bg-brand-rose-bg"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="#7C3A4B" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</Button>
{/* Category Badge */}
<span
className={`absolute top-2 left-2 rounded-full px-3 py-1 text-xs font-semibold ${categoryBadge[asset.category]}`}
>
{asset.category === 'image'
? 'Image'
: asset.category === 'video'
? 'Video'
: 'Text'}
</span>
{/* Video Duration Overlay */}
{asset.category === 'video' && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/50 backdrop-blur-sm rounded-full px-2 py-1">
<VideoFilled size={10} className="text-white" />
<span className="text-xs text-white font-medium">Video</span>
</div>
)}
</div>
{/* Info */}
<div className="p-4">
<p className="text-sm font-medium text-brand-navy truncate mb-1">
{asset.name}
</p>
<p className="text-xs text-slate-400">{asset.size}</p>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
</>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,68 @@
import { motion } from 'motion/react';
import { useNavigate, useParams } from 'react-router';
import { RocketFilled, DownloadFilled } from '@/shared/icons/FilledIcons';
import { Button } from '@/shared/ui/button';
import { useExportPDF } from '@/features/report/hooks/useExportPDF';
export default function PlanCTA() {
const { exportPDF, isExporting } = useExportPDF();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
return (
<motion.section
className="py-16 md:py-20 px-6"
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<div className="max-w-7xl mx-auto">
<div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky p-10 md:p-14 text-center"
>
<div className="flex justify-center mb-6">
<div className="w-14 h-14 rounded-full bg-white/80 backdrop-blur-sm border border-white/40 flex items-center justify-center">
<RocketFilled size={28} className="text-brand-purple" />
</div>
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-brand-purple-deep mb-3">
</h3>
<p className="text-brand-purple-deep/60 mb-8 max-w-lg mx-auto">
INFINITH , .
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
type="button"
onClick={() => navigate(`/studio/${id || 'live'}`)}
className="inline-flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-brand-purple to-brand-purple-deep px-6 py-3 h-auto text-sm font-medium text-white shadow-md hover:shadow-lg hover:from-brand-purple hover:to-brand-purple-deep transition-shadow"
>
</Button>
<Button
type="button"
variant="outline"
onClick={() => exportPDF('INFINITH_Marketing_Plan')}
disabled={isExporting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-white border-slate-200 px-6 py-3 h-auto text-sm font-medium text-brand-purple-deep shadow-sm hover:shadow-md hover:bg-white transition-shadow disabled:opacity-60"
>
{isExporting ? (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
) : (
<DownloadFilled size={16} />
)}
</Button>
</div>
</div>
</div>
</motion.section>
);
}

View File

@ -0,0 +1,89 @@
import { motion } from 'motion/react';
import { CalendarFilled, GlobeFilled } from '@/shared/icons/FilledIcons';
function formatDate(raw: string): string {
try {
return new Date(raw).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return raw;
}
}
interface PlanHeaderProps {
clinicName: string;
clinicNameEn: string;
date: string;
targetUrl: string;
}
export default function PlanHeader({
clinicName,
clinicNameEn,
date,
targetUrl,
}: PlanHeaderProps) {
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 md:py-28 px-6">
{/* Animated blobs — position only, no opacity */}
<motion.div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-[#6C5CE7]/10 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-[#D5CDF5]/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-[#6C5CE7]/8 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 — plain div, no opacity animation */}
<div className="flex-1 text-center md:text-left">
<p className="text-xs font-semibold text-[#6C5CE7] mb-4 tracking-widest uppercase">
Marketing Execution Plan
</p>
<h1 className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3">
{clinicName}
</h1>
<p className="text-xl text-slate-600 mb-8">
{clinicNameEn}
</p>
<div className="flex flex-wrap gap-3 justify-center md:justify-start">
<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">
<CalendarFilled 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">
<GlobeFilled size={14} className="text-slate-400" />
{targetUrl}
</span>
</div>
</div>
{/* Right: 90 Days badge */}
<div className="shrink-0">
<div className="w-32 h-32 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] flex flex-col items-center justify-center shadow-lg">
<span className="text-4xl font-bold text-white leading-none">
90
</span>
<span className="text-sm text-white/60">Days</span>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,154 @@
import { useState } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
VideoFilled,
RefreshFilled,
BoltFilled,
} from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import type { RepurposingProposalItem } from '@/features/plan/types/plan';
interface RepurposingProposalProps {
proposals: RepurposingProposalItem[];
}
// 채널 키워드 → FilledIcon 매핑
function ChannelIcon({ channel, size = 14 }: { channel: string; size?: number }) {
const lower = channel.toLowerCase();
const className = 'shrink-0';
if (lower.includes('youtube')) return <YoutubeFilled size={size} className={className} />;
if (lower.includes('instagram')) return <InstagramFilled size={size} className={className} />;
if (lower.includes('facebook')) return <FacebookFilled size={size} className={className} />;
if (lower.includes('tiktok')) return <TiktokFilled size={size} className={className} />;
if (lower.includes('naver') || lower.includes('blog')) return <GlobeFilled size={size} className={className} />;
return <VideoFilled size={size} className={className} />;
}
const effortConfig: Record<string, { label: string; bg: string; text: string; border: string }> = {
low: { label: '빠른 작업', bg: 'bg-brand-tint-purple', text: 'text-brand-purple-muted', border: 'border-brand-tint-lavender' },
medium: { label: '중간 작업', bg: 'bg-brand-earth-bg', text: 'text-brand-earth', border: 'border-brand-earth-soft' },
high: { label: '집중 작업', bg: 'bg-brand-rose-bg', text: 'text-brand-rose', border: 'border-brand-rose-soft' },
};
const priorityConfig: Record<string, { label: string; dot: string }> = {
high: { label: 'P0', dot: 'bg-brand-rose-mid' },
medium: { label: 'P1', dot: 'bg-[#D4A872]' },
low: { label: 'P2', dot: 'bg-brand-purple-soft' },
};
function formatViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}
export default function RepurposingProposal({ proposals }: RepurposingProposalProps) {
const [expandedIdx, setExpandedIdx] = useState<number | null>(0);
return (
<SectionWrapper
id="repurposing-proposal"
title="Repurposing Proposal"
subtitle="콘텐츠 리퍼포징 제안"
>
<div className="space-y-4">
{proposals.map((item, idx) => {
const effort = effortConfig[item.estimatedEffort];
const priority = priorityConfig[item.priority];
const isExpanded = expandedIdx === idx;
return (
<div
key={item.sourceVideo.title}
className="rounded-2xl border border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
>
{/* Card Header — click to expand */}
<Button
type="button"
variant="ghost"
className="w-full flex items-center gap-4 px-6 py-4 h-auto text-left hover:bg-slate-50/50 rounded-none"
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
>
{/* Source video info */}
<div className="w-10 h-10 rounded-xl bg-brand-tint-purple flex items-center justify-center shrink-0">
<YoutubeFilled size={20} className="text-brand-purple-vivid" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-brand-navy text-sm truncate">{item.sourceVideo.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{formatViews(item.sourceVideo.views)} views</span>
<span className="w-1 h-1 rounded-full bg-slate-300" />
<span className="text-xs text-slate-500">{item.sourceVideo.type === 'Short' ? 'Shorts' : 'Long-form'}</span>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2 shrink-0">
{/* Output count */}
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-brand-tint-purple border border-brand-tint-lavender">
<RefreshFilled size={11} className="text-brand-purple-vivid" />
<span className="text-xs font-semibold text-brand-purple-muted">{item.outputs.length} </span>
</div>
{/* Effort */}
<span className={`text-xs font-medium px-2.5 py-1 rounded-full border ${effort.bg} ${effort.text} ${effort.border} hidden sm:inline-flex`}>
{effort.label}
</span>
{/* Priority */}
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-slate-50 border border-slate-200">
<span className={`w-1.5 h-1.5 rounded-full ${priority.dot}`} />
<span className="text-xs font-bold text-slate-600">{priority.label}</span>
</div>
{/* Chevron */}
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</Button>
{/* Expanded: Repurpose outputs */}
{isExpanded && (
<div className="border-t border-slate-100">
<div className="px-6 py-4">
<div className="flex items-center gap-2 mb-4">
<BoltFilled size={14} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">REPURPOSE AS</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{item.outputs.map((output) => (
<div
key={output.format}
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 border border-slate-100"
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-purple-vivid/10 to-brand-purple/10 flex items-center justify-center shrink-0">
<ChannelIcon channel={output.channel} size={16} />
</div>
<div className="min-w-0">
<p className="font-semibold text-brand-navy text-sm">{output.format}</p>
<p className="text-xs text-brand-purple-vivid font-medium mb-1">{output.channel}</p>
<p className="text-xs text-slate-500 leading-relaxed">{output.description}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,263 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { MegaphoneFilled } from '@/shared/icons/FilledIcons';
// TODO(migration): 백엔드에 대응 엔드포인트 없음 - 추후 연결 필요
// performance_metrics / strategy_adjustments / triggerStrategyAdjustment 엔드포인트는
// 아직 FastAPI 백엔드에 노출되지 않음. UI 컴파일을 위해 임시 no-op 사용.
interface StrategyAdjustmentSectionProps {
clinicId: string | null;
planId?: string;
}
interface KpiProgress {
metric: string;
current: string;
target: string;
progress: number;
}
interface StrategySuggestion {
adjustmentType: string;
channel: string;
description: string;
reason: string;
priority: 'high' | 'medium' | 'low';
beforeValue: string;
afterValue: string;
}
interface AdjustmentHistoryItem {
id: string;
adjustment_type: string;
description: string;
reason: string;
created_at: string;
}
const priorityColors: Record<string, { bg: string; text: string; dot: string }> = {
high: { bg: 'bg-red-50', text: 'text-red-700', dot: 'bg-red-400' },
medium: { bg: 'bg-amber-50', text: 'text-amber-700', dot: 'bg-amber-400' },
low: { bg: 'bg-blue-50', text: 'text-blue-700', dot: 'bg-blue-400' },
};
const typeLabels: Record<string, string> = {
frequency_change: '게시 빈도 조정',
pillar_shift: '콘텐츠 필러 전환',
channel_add: '채널 추가',
channel_pause: '채널 일시 중지',
content_format_change: '포맷 변경',
tone_shift: '톤 조정',
other: '기타 조정',
};
export default function StrategyAdjustmentSection({ clinicId, planId: _planId }: StrategyAdjustmentSectionProps) {
const [kpiProgress] = useState<KpiProgress[]>([]);
const [suggestions] = useState<StrategySuggestion[]>([]);
const [history] = useState<AdjustmentHistoryItem[]>([]);
const [overallAssessment] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [hasData] = useState(false);
useEffect(() => {
if (!clinicId) return;
// TODO(migration): 백엔드에 대응 엔드포인트 없음 - 추후 연결 필요
setIsLoading(false);
}, [clinicId]);
async function handleRunAdjustment() {
if (!clinicId || isRunning) return;
setIsRunning(true);
try {
// TODO(migration): 백엔드에 'trigger-strategy-adjustment' 대응 엔드포인트 없음 - 추후 연결 필요
} catch (err) {
console.error('Strategy adjustment failed:', err);
} finally {
setIsRunning(false);
}
}
if (isLoading) {
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="전략 조정" dark>
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-2 border-purple-300 border-t-purple-600 rounded-full animate-spin" />
</div>
</SectionWrapper>
);
}
return (
<SectionWrapper id="strategy-adjustment" title="Strategy Adjustment" subtitle="성과 기반 전략 조정" dark>
{/* Action Button */}
<div className="flex items-center justify-between mb-6" data-no-print>
<p className="text-sm text-slate-500">
</p>
<button
onClick={handleRunAdjustment}
disabled={isRunning}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
isRunning
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-[#6C5CE7] text-white hover:bg-[#5A4BD6] shadow-sm'
}`}
>
{isRunning ? (
<>
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-500 rounded-full animate-spin" />
...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</>
)}
</button>
</div>
{!hasData ? (
/* Empty state */
<div className="text-center py-12 bg-white rounded-2xl border border-dashed border-slate-200">
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-3">
<MegaphoneFilled size={20} className="text-[#6C5CE7]" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1"> </p>
<p className="text-xs text-slate-400">
2
</p>
</div>
) : (
<>
{/* Overall Assessment */}
{overallAssessment && (
<motion.div
className="bg-purple-50 border border-purple-100 rounded-2xl p-4 mb-6"
animate={{ opacity: 1, y: 0 }}
>
<p className="text-sm text-purple-800">{overallAssessment}</p>
</motion.div>
)}
{/* KPI Progress */}
{kpiProgress.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3">KPI </h4>
<div className="space-y-3">
{kpiProgress.map((kpi, i) => (
<motion.div
key={i}
className="bg-white rounded-xl p-4 border border-slate-100"
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-slate-600">{kpi.metric}</span>
<span className={`text-xs font-bold ${
kpi.progress >= 100 ? 'text-green-600' :
kpi.progress >= 70 ? 'text-amber-600' : 'text-red-600'
}`}>
{kpi.progress}%
</span>
</div>
<div className="w-full h-2 bg-slate-100 rounded-full overflow-hidden">
<motion.div
className={`h-full rounded-full ${
kpi.progress >= 100 ? 'bg-green-400' :
kpi.progress >= 70 ? 'bg-amber-400' : 'bg-red-400'
}`}
initial={{ width: 0 }}
animate={{ width: `${Math.min(kpi.progress, 100)}%` }}
transition={{ duration: 0.8, delay: i * 0.1 }}
/>
</div>
<div className="flex justify-between mt-1.5 text-[10px] text-slate-400">
<span>: {kpi.current}</span>
<span>: {kpi.target}</span>
</div>
</motion.div>
))}
</div>
</div>
)}
{/* Strategy Suggestions */}
{suggestions.length > 0 && (
<div className="mb-8">
<h4 className="text-sm font-bold text-[#0A1128] mb-3"> </h4>
<div className="space-y-3">
{suggestions.map((s, i) => {
const colors = priorityColors[s.priority] || priorityColors.medium;
return (
<motion.div
key={i}
className={`${colors.bg} rounded-xl p-4 border border-slate-100`}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08 }}
>
<div className="flex items-start gap-3">
<span className={`w-2 h-2 rounded-full ${colors.dot} mt-1.5 flex-shrink-0`} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-bold ${colors.text}`}>
{typeLabels[s.adjustmentType] || s.adjustmentType}
</span>
{s.channel && (
<span className="text-[10px] text-slate-400 bg-white px-2 py-0.5 rounded-full">
{s.channel}
</span>
)}
</div>
<p className="text-sm text-slate-700 mb-1">{s.description}</p>
<p className="text-xs text-slate-500">{s.reason}</p>
{s.beforeValue && s.afterValue && (
<div className="flex items-center gap-2 mt-2 text-xs">
<span className="text-slate-400 line-through">{s.beforeValue}</span>
<span className="text-slate-400"></span>
<span className={`font-medium ${colors.text}`}>{s.afterValue}</span>
</div>
)}
</div>
</div>
</motion.div>
);
})}
</div>
</div>
)}
{/* Adjustment History */}
{history.length > 0 && (
<div>
<h4 className="text-sm font-bold text-[#0A1128] mb-3"> </h4>
<div className="relative border-l-2 border-slate-200 ml-2 pl-4 space-y-4">
{history.map((item) => (
<div key={item.id} className="relative">
<div className="absolute -left-[21px] top-1 w-2.5 h-2.5 rounded-full bg-purple-300 border-2 border-white" />
<div className="bg-white rounded-xl p-3 border border-slate-100">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-purple-600">
{typeLabels[item.adjustment_type] || item.adjustment_type}
</span>
<span className="text-[10px] text-slate-400">
{new Date(item.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<p className="text-xs text-slate-600">{item.description}</p>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,378 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
GlobeFilled,
VideoFilled,
BoltFilled,
CheckFilled,
CalendarFilled,
FileTextFilled,
} from '@/shared/icons/FilledIcons';
import { SectionWrapper } from '@/features/report/components/ui/SectionWrapper';
import { Button } from '@/shared/ui/button';
import { Textarea } from '@/shared/ui/textarea';
import { STAGES, STAGE_COLORS, channelIconMap } from '../data/workflowTrackerConstants';
import type { WorkflowData, WorkflowItem, WorkflowStage, WorkflowContentType } from '@/features/plan/types/plan';
interface WorkflowTrackerProps {
data: WorkflowData;
}
function StageBar({ currentStage }: { currentStage: WorkflowStage }) {
const currentIdx = STAGES.findIndex((s) => s.key === currentStage);
return (
<div className="flex items-center gap-0 mb-4">
{STAGES.map((stage, idx) => {
const isPast = idx < currentIdx;
const isCurrent = idx === currentIdx;
return (
<div key={stage.key} className="flex items-center flex-1 min-w-0">
<div className={`flex flex-col items-center flex-1 min-w-0`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 transition-colors ${
isCurrent
? 'bg-brand-purple-vivid text-white shadow-[0_0_0_3px_rgba(108,92,231,0.2)]'
: isPast
? 'bg-brand-purple-soft text-white'
: 'bg-slate-200 text-slate-400'
}`}
>
{isPast ? <CheckFilled size={10} /> : idx + 1}
</div>
<span className={`text-[9px] mt-1 font-medium whitespace-nowrap ${isCurrent ? 'text-brand-purple-vivid' : isPast ? 'text-brand-purple-soft' : 'text-slate-400'}`}>
{stage.short}
</span>
</div>
{idx < STAGES.length - 1 && (
<div className={`h-0.5 flex-1 mx-1 rounded-full transition-colors ${idx < currentIdx ? 'bg-brand-purple-soft' : 'bg-slate-200'}`} />
)}
</div>
);
})}
</div>
);
}
function WorkflowCard({ item, onStageChange, onNotesChange }: {
item: WorkflowItem;
onStageChange: (id: string, stage: WorkflowStage) => void;
onNotesChange: (id: string, notes: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesValue, setNotesValue] = useState(item.userNotes ?? '');
const stageColor = STAGE_COLORS[item.stage];
const ChannelIcon = channelIconMap[item.channelIcon] ?? GlobeFilled;
const currentStageIdx = STAGES.findIndex((s) => s.key === item.stage);
const nextStage = STAGES[currentStageIdx + 1];
const saveNotes = () => {
onNotesChange(item.id, notesValue);
setEditingNotes(false);
};
return (
<motion.div
layout
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.3 }}
>
{/* Card Header */}
<Button
type="button"
variant="ghost"
className="w-full flex items-center gap-3 px-5 py-4 h-auto text-left hover:bg-slate-50/50 rounded-none"
onClick={() => setExpanded((p) => !p)}
>
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 ${stageColor.bg} ${stageColor.border} border`}>
<ChannelIcon size={16} className={stageColor.text} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-brand-navy text-sm truncate">{item.title}</p>
<p className="text-xs text-slate-500 mt-0.5">{item.channel}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Stage badge */}
<span className={`text-[10px] font-semibold px-2.5 py-1 rounded-full border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}>
{STAGES.find((s) => s.key === item.stage)?.label}
</span>
{item.scheduledDate && (
<div className="flex items-center gap-1 text-[10px] text-slate-500">
<CalendarFilled size={10} />
<span>{item.scheduledDate.slice(5)}</span>
</div>
)}
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</Button>
{/* Expanded Body */}
<AnimatePresence>
{expanded && (
<motion.div
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-slate-100"
>
<div className="px-5 py-4 space-y-4">
{/* Stage Progress */}
<StageBar currentStage={item.stage} />
{/* AI Draft Content */}
{item.contentType === 'video' && item.videoDraft && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BoltFilled size={13} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">AI </p>
<span className="text-[10px] text-slate-400 font-mono">{item.videoDraft.duration}</span>
</div>
<pre className="text-xs text-slate-700 bg-slate-50 rounded-xl p-4 whitespace-pre-wrap leading-relaxed border border-slate-100 font-sans">
{item.videoDraft.script}
</pre>
<div className="flex items-center gap-2 mt-2">
<FileTextFilled size={13} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest"> </p>
</div>
<ul className="space-y-1.5">
{item.videoDraft.shootingGuide.map((guide, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-600">
<span className="w-4 h-4 rounded-full bg-brand-tint-purple text-brand-purple-vivid flex items-center justify-center text-[9px] font-bold shrink-0 mt-0.5">{i + 1}</span>
{guide}
</li>
))}
</ul>
</div>
)}
{item.contentType === 'image-text' && item.imageTextDraft && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BoltFilled size={13} className="text-brand-purple-vivid" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">
AI {item.imageTextDraft.type === 'cardnews' ? '카드뉴스 카피' : '블로그 초안'}
</p>
</div>
<div className="bg-slate-50 rounded-xl p-4 border border-slate-100">
<p className="font-bold text-sm text-brand-navy mb-3">{item.imageTextDraft.headline}</p>
<ul className="space-y-2">
{item.imageTextDraft.copy.map((line, i) => (
<li key={i} className="text-xs text-slate-600 leading-relaxed whitespace-pre-line">{line}</li>
))}
</ul>
</div>
{item.imageTextDraft.layoutHint && (
<p className="text-[10px] text-slate-400 italic">{item.imageTextDraft.layoutHint}</p>
)}
</div>
)}
{/* User Notes */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-semibold text-slate-500"> / </p>
{!editingNotes && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); setEditingNotes(true); }}
className="text-[10px] h-auto px-2 py-1 text-brand-purple-vivid hover:text-brand-purple-muted hover:bg-transparent font-medium"
>
{notesValue ? '편집' : '+ 추가'}
</Button>
)}
</div>
{editingNotes ? (
<div className="space-y-2">
<Textarea
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
placeholder="수정할 내용이나 추가 지시사항을 입력하세요..."
className="w-full text-xs text-slate-700 border-slate-200 rounded-xl px-3 py-2.5 resize-none focus-visible:ring-brand-purple-vivid/30 focus-visible:border-brand-purple-vivid min-h-[80px] placeholder:text-slate-300"
autoFocus
/>
<div className="flex gap-2">
<Button
type="button"
size="sm"
onClick={saveNotes}
className="text-xs px-3 py-1.5 h-auto rounded-lg bg-brand-purple-vivid text-white font-medium hover:bg-[#5A4DD4]"
>
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setNotesValue(item.userNotes ?? ''); setEditingNotes(false); }}
className="text-xs px-3 py-1.5 h-auto rounded-lg bg-slate-100 text-slate-500 font-medium hover:bg-slate-200"
>
</Button>
</div>
</div>
) : notesValue ? (
<p className="text-xs text-slate-600 bg-brand-earth-bg border border-brand-earth-soft rounded-xl px-3 py-2.5 leading-relaxed">
{notesValue}
</p>
) : (
<p className="text-xs text-slate-300 italic"></p>
)}
</div>
{/* Stage Advance Button */}
{nextStage && item.stage !== 'scheduled' && (
<Button
type="button"
onClick={(e) => { e.stopPropagation(); onStageChange(item.id, nextStage.key); }}
className="w-full flex items-center justify-center gap-2 py-2.5 h-auto rounded-xl bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white text-xs font-semibold hover:from-brand-purple hover:to-brand-purple-deep hover:opacity-90"
>
<span>{nextStage.label}() </span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</Button>
)}
{item.stage === 'scheduled' && (
<div className="flex items-center justify-center gap-2 py-2.5 rounded-xl bg-brand-tint-purple border border-brand-tint-lavender">
<CheckFilled size={14} className="text-brand-purple-vivid" />
<span className="text-xs font-semibold text-brand-purple-muted"> </span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default function WorkflowTracker({ data }: WorkflowTrackerProps) {
const [items, setItems] = useState<WorkflowItem[]>(data.items);
const [activeTab, setActiveTab] = useState<WorkflowContentType>('video');
const [activeStageFilter, setActiveStageFilter] = useState<WorkflowStage | null>(null);
const handleStageChange = useCallback((id: string, stage: WorkflowStage) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, stage } : item));
}, []);
const handleNotesChange = useCallback((id: string, notes: string) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, userNotes: notes } : item));
}, []);
const filtered = items.filter((item) => {
if (item.contentType !== activeTab) return false;
if (activeStageFilter && item.stage !== activeStageFilter) return false;
return true;
});
// 현재 탭의 단계별 개수
const stageCounts = STAGES.map((s) => ({
...s,
count: items.filter((i) => i.contentType === activeTab && i.stage === s.key).length,
}));
return (
<SectionWrapper
id="workflow-tracker"
title="Workflow Tracker"
subtitle="콘텐츠 제작 파이프라인"
dark
>
{/* Content Type Tabs */}
<div className="flex bg-white/10 rounded-xl p-0.5 mb-6 w-fit" data-no-print>
{(['video', 'image-text'] as WorkflowContentType[]).map((type) => (
<Button
type="button"
variant="ghost"
size="sm"
key={type}
onClick={() => { setActiveTab(type); setActiveStageFilter(null); }}
className={`flex items-center gap-2 px-4 py-1.5 h-auto text-xs font-medium rounded-lg ${
activeTab === type
? 'bg-white/20 text-white shadow-sm hover:bg-white/20 hover:text-white'
: 'text-white/50 hover:text-white/80 hover:bg-transparent'
}`}
>
{type === 'video'
? <VideoFilled size={12} />
: <FileTextFilled size={12} />
}
{type === 'video' ? '동영상 콘텐츠' : '이미지+텍스트'}
</Button>
))}
</div>
{/* Stage Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{stageCounts.map((stage) => {
if (stage.count === 0) return null;
const isActive = activeStageFilter === stage.key;
const color = STAGE_COLORS[stage.key];
return (
<motion.button
type="button"
key={stage.key}
onClick={() => setActiveStageFilter(isActive ? null : stage.key)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-medium transition-all ${color.bg} ${color.text} ${color.border} ${
isActive ? 'ring-2 ring-white/40' : ''
} ${activeStageFilter && !isActive ? 'opacity-40' : ''}`}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`} />
{stage.label}
<span className="font-bold">{stage.count}</span>
</motion.button>
);
})}
{activeStageFilter && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setActiveStageFilter(null)}
className="text-xs h-auto py-1 text-white/40 hover:text-white/70 hover:bg-transparent px-2"
>
×
</Button>
)}
</div>
{/* Card List */}
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{filtered.length === 0 ? (
<motion.div
animate={{ opacity: 1 }}
className="text-center py-10 text-white/30 text-sm"
>
</motion.div>
) : (
filtered.map((item: WorkflowItem) => (
<WorkflowCard
key={item.id}
item={item}
onStageChange={handleStageChange}
onNotesChange={handleNotesChange}
/>
))
)}
</AnimatePresence>
</div>
</SectionWrapper>
);
}

View File

@ -0,0 +1,43 @@
import type { ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
} from '@/shared/icons/FilledIcons';
export const tabItems = [
{ key: 'visual', label: 'Visual Identity', labelKr: '비주얼 아이덴티티' },
{ key: 'tone', label: 'Tone & Voice', labelKr: '톤 & 보이스' },
{ key: 'channels', label: 'Channel Rules', labelKr: '채널별 규칙' },
{ key: 'consistency', label: 'Brand Consistency', labelKr: '브랜드 일관성' },
] as const;
export type TabKey = (typeof tabItems)[number]['key'];
export const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
};
export function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
export const statusColor: Record<string, string> = {
correct: 'bg-brand-tint-purple text-brand-purple-muted border-brand-tint-lavender',
incorrect: 'bg-brand-rose-bg text-brand-rose border-brand-rose-soft',
missing: 'bg-slate-100 text-slate-500 border-slate-200',
};
export const statusLabel: Record<string, string> = {
correct: 'Correct',
incorrect: 'Incorrect',
missing: 'Missing',
};

View File

@ -0,0 +1,56 @@
import {
VideoFilled,
FileTextFilled,
ShareFilled,
MegaphoneFilled,
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
MessageFilled,
} from '@/shared/icons/FilledIcons';
import type { ContentCategory } from '@/features/plan/types/plan';
export const contentTypeColors: Record<ContentCategory, { bg: string; text: string; entry: string; border: string; shadow: string }> = {
video: { bg: 'bg-brand-tint-purple', text: 'text-brand-purple-vivid', entry: 'bg-[#EDE5FF] border-[#B8A8E8]', border: 'border-brand-tint-lavender', shadow: 'shadow-[2px_3px_8px_rgba(155,138,212,0.15)]' },
blog: { bg: 'bg-brand-tint-violet', text: 'text-brand-purple-faint', entry: 'bg-[#E2E5FF] border-[#A8B0E8]', border: 'border-[#C5CBF5]', shadow: 'shadow-[2px_3px_8px_rgba(122,132,212,0.15)]' },
social: { bg: 'bg-brand-earth-bg', text: 'text-brand-earth', entry: 'bg-[#FFEED9] border-[#E8C896]', border: 'border-brand-earth-soft', shadow: 'shadow-[2px_3px_8px_rgba(212,168,114,0.15)]' },
ad: { bg: 'bg-brand-rose-bg', text: 'text-brand-rose', entry: 'bg-[#FFE0E0] border-[#E8A8B4]', border: 'border-brand-rose-soft', shadow: 'shadow-[2px_3px_8px_rgba(212,136,154,0.15)]' },
};
export const contentTypeLabels: Record<ContentCategory, string> = {
video: 'Video',
blog: 'Blog',
social: 'Social',
ad: 'Ad',
};
export const contentTypeIcons: Record<ContentCategory, typeof VideoFilled> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
ad: MegaphoneFilled,
};
// 캘린더 항목의 채널 식별용 FilledIcon 매핑
export const channelIconMap: Record<string, typeof VideoFilled> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
blog: FileTextFilled,
globe: GlobeFilled,
video: TiktokFilled,
star: GlobeFilled,
map: GlobeFilled,
messagesquare: MessageFilled,
};
// 시맨틱 상태 색상 (디자인 시스템 기준 raw green/red 미사용)
export const statusDotColors: Record<string, string> = {
draft: 'bg-slate-400',
approved: 'bg-brand-purple-soft',
published: 'bg-[#7A84D4]',
};
export const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];

View File

@ -0,0 +1,425 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
export const mockPlan: MarketingPlan = {
id: 'view-clinic',
reportId: 'view-clinic',
clinicName: '뷰성형외과의원',
clinicNameEn: 'VIEW Plastic Surgery',
createdAt: '2026-04-28',
targetUrl: 'https://www.viewclinic.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'VIEW Purple', hex: '#7B2D8E', usage: '공식 로고 메인 컬러, 깃털 아이콘, 브랜드 텍스트' },
{ name: 'VIEW Gold', hex: '#E8B931', usage: '깃털 악센트, 강조 요소, CTA 포인트' },
{ name: 'VIEW Text Purple', hex: '#6B2D7B', usage: '한글/영문 브랜드명, 헤딩 텍스트' },
{ name: 'Warm White', hex: '#FAF8F5', usage: '배경, 카드, 여백 공간' },
{ name: 'Deep Charcoal', hex: '#2D2D2D', usage: '본문 텍스트, 서브 텍스트' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀, CTA 버튼', sampleText: '안전이 예술이 되는 곳' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트, 설명, 캡션', sampleText: '21년 무사고 VIEW 성형외과' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩, 프리미엄 강조', sampleText: 'VIEW Plastic Surgery' },
],
logoRules: [
{ rule: '보라색+골드 깃털 로고 통일 사용', description: '공식 깃털 심볼(보라색+골드) + VIEW 텍스트를 모든 채널에서 동일하게 사용', correct: true },
{ rule: '원형 로고: 보라색 테두리 버전', description: '프로필 사진용 원형 버전은 보라색 원 테두리 안에 깃털 심볼 + VIEW 텍스트 배치', correct: true },
{ rule: '가로형 로고: 깃털 + 텍스트 조합', description: '배너, 헤더에는 깃털 심볼 옆에 View Plastic Surgery 텍스트를 가로 배치', correct: true },
{ rule: '모델 사진 프로필 금지', description: '프로필 사진에 모델/환자 사진 대신 반드시 공식 깃털 로고 사용 (Instagram KR 위반 중)', correct: false },
{ rule: '비공식 변형 로고 사용 금지', description: 'YouTube의 VIEW 골드 텍스트 전용 로고는 비공식 — 깃털 심볼이 반드시 포함되어야 함', correct: false },
{ rule: '로고 주변 여백 확보', description: '로고 크기의 50% 이상 여백을 유지하여 가독성 확보', correct: true },
],
toneOfVoice: {
personality: ['차분한 전문가', '신뢰감 있는', '과장 없는', '환자 중심', '결과로 증명하는'],
communicationStyle: '환자의 불안과 고민을 이해하고, 전문적인 판단력으로 신뢰를 구축합니다. 유행을 좇지 않고 원칙을 말하는 병원으로서, 과장된 표현 대신 정확한 정보와 설명으로 설득합니다.',
doExamples: [
'"수술을 권하기 전에, 판단을 설명합니다"',
'"결과가 설명되는 수술"',
'"21년간 안전을 최우선으로"',
'"환자의 관점에서 생각합니다"',
],
dontExamples: [
'"강남 최고! 파격 할인!"',
'"연예인이 선택한 병원"',
'"이 가격은 오늘까지만!"',
'"100% 만족 보장"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '2560x1440px, 퍼플+골드 그라디언트 배경, 깃털 심볼 + "VIEW Plastic Surgery" 슬로건', bioTemplate: '안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과\n02-539-1177 | 카톡: @뷰성형외과의원', currentStatus: 'incorrect' },
{ channel: 'Instagram KR', icon: 'instagram', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A (하이라이트 커버: 퍼플 톤 아이콘 세트)', bioTemplate: '안전이 예술이 되는 곳 — VIEW 성형외과\n신논현역 3번 출구 | 02-539-1177\nviewclinic.com', currentStatus: 'incorrect' },
{ channel: 'Instagram EN', icon: 'instagram', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A', bioTemplate: 'Where Safety Becomes Art — VIEW Plastic Surgery\nGangnam, Seoul | +82-2-539-1177\nviewclinic.com/en', currentStatus: 'incorrect' },
{ channel: 'Facebook KR', icon: 'facebook', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '820x312px, 퍼플+골드 배너, 깃털 심볼 + 슬로건', bioTemplate: '안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과', currentStatus: 'correct' },
{ channel: 'Facebook EN', icon: 'facebook', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: '820x312px, 동일 디자인 시스템', bioTemplate: 'Where Safety Becomes Art — VIEW Plastic Surgery', currentStatus: 'incorrect' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: '보라색+골드 깃털 로고', bannerSpec: '블로그 상단: 깃털 심볼 + 대표 이미지', bioTemplate: '21년 무사고 VIEW 성형외과 공식 블로그\n가슴성형·안면윤곽·양악·눈코·리프팅', currentStatus: 'missing' },
{ channel: 'TikTok', icon: 'video', profilePhoto: '보라색+골드 깃털 원형 로고', bannerSpec: 'N/A', bioTemplate: 'VIEW 성형외과 — 안전이 예술이 되는 곳\n강남 신논현역 | 02-539-1177', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '로고',
values: [
{ channel: 'YouTube', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Instagram KR', value: '모델 프로필 사진 (로고 아님)', isCorrect: false },
{ channel: 'Instagram EN', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Facebook KR', value: '보라색+골드 깃털 로고 (공식 로고)', isCorrect: true },
{ channel: 'Facebook EN', value: 'VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)', isCorrect: false },
{ channel: 'Website', value: '보라색+골드 깃털 로고 (공식 로고)', isCorrect: true },
],
impact: '공식 깃털 로고를 사용하는 채널은 Facebook KR과 웹사이트 2곳뿐. 나머지 4개 채널은 비공식 변형 로고 또는 모델 사진을 사용',
recommendation: '전 채널에 보라색+골드 깃털 공식 로고 통일 적용 (원형 버전: 프로필, 가로형 버전: 배너)',
},
{
field: '바이오/소개 메시지',
values: [
{ channel: 'YouTube', value: '💜뷰성형외과💜 VIEW가 예술이다!', isCorrect: false },
{ channel: 'Instagram KR', value: '뷰 성형외과 | 가슴성형·안면윤곽·눈성형', isCorrect: false },
{ channel: 'Facebook KR', value: '예쁨이 일상이 되는 순간!', isCorrect: false },
{ channel: 'Facebook EN', value: 'Official Account by VIEW Partners', isCorrect: false },
],
impact: '4개 채널, 4개의 서로 다른 소개 메시지 → 통일된 브랜드 포지셔닝 부재',
recommendation: '핵심 USP 포함 통일 바이오: "안전이 예술이 되는 곳 — 21년 무사고 VIEW"',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '104K 구독자, 주 2~3회 업로드', targetGoal: '200K 구독자, 주 3회 업로드',
contentTypes: ['Shorts', 'Long-form', 'Community'],
postingFrequency: '주 3회 (롱폼 1 + Shorts 2)',
tone: '차분한 전문가 — 원장이 직접 설명하는 교육 콘텐츠',
formatGuidelines: ['Shorts: 15-60초, 세로형, 후크 3초 내', 'Long-form: 5-15분, 원장 설명 + B-roll', '썸네일: VIEW 골드 워터마크 + 통일 폰트'],
priority: 'P0',
},
{
channelId: 'instagram_kr', channelName: 'Instagram KR', icon: 'instagram',
currentStatus: '14,047 팔로워, Reels 0개', targetGoal: '50K 팔로워, Reels 주 5개',
contentTypes: ['Reels', 'Carousel', 'Stories', 'Feed Image'],
postingFrequency: '일 1회 + Stories 일 2-3개',
tone: '차분하지만 접근 가능한 — 환자 관점의 Q&A',
formatGuidelines: ['Reels: YouTube Shorts 동시 게시', 'Carousel: 시술 가이드 5-7장', 'Stories: 병원 일상, 상담 비하인드, 투표'],
priority: 'P0',
},
{
channelId: 'instagram_en', channelName: 'Instagram EN', icon: 'instagram',
currentStatus: '70,537 팔로워, Reels 활발', targetGoal: '120K 팔로워',
contentTypes: ['Reels', 'Before/After', 'Patient Stories'],
postingFrequency: '주 5회',
tone: 'Professional & warm — medical tourism storytelling',
formatGuidelines: ['Patient journey videos (English subtitles)', 'Before/After with consent', 'Korea travel + surgery content'],
priority: 'P1',
},
{
channelId: 'facebook', channelName: 'Facebook', icon: 'facebook',
currentStatus: 'KR 254명 + EN 88,333명, 로고 불일치', targetGoal: '통합 관리, 광고 리타겟 전용',
contentTypes: ['광고 크리에이티브', '리타겟 콘텐츠'],
postingFrequency: '주 2-3회 (광고 소재 위주)',
tone: '신뢰 기반 — 안전, 경험, 결과 강조',
formatGuidelines: ['KR 페이지 폐쇄 → EN 페이지로 통합', 'Facebook Pixel 리타겟 광고 최적화', '로고 VIEW 골드로 즉시 교체'],
priority: 'P1',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '활성 — 551개 게시글, 월 2~3회 포스팅 (최근 2026.4.22)', targetGoal: '주 2회 포스팅, 월 30,000 방문자',
contentTypes: ['SEO 블로그 포스트', '시술 가이드', '환자 후기'],
postingFrequency: '주 3회',
tone: '정보성 전문가 — 키워드 중심, 환자 고민 해결',
formatGuidelines: ['2,000자 이상 SEO 최적화 포스트', '시술별 FAQ 시리즈', '이미지 10장 이상 + 동영상 임베드'],
priority: 'P0',
},
{
channelId: 'tiktok', channelName: 'TikTok', icon: 'video',
currentStatus: '계정 없음', targetGoal: '10K 팔로워',
contentTypes: ['Shorts 크로스포스팅', '트렌드 챌린지'],
postingFrequency: '주 5회 (YouTube Shorts 동시 배포)',
tone: '가볍고 접근 가능한 — 20~30대 타겟',
formatGuidelines: ['YouTube Shorts 동시 업로드', '트렌딩 사운드 활용', '자막 필수 (음소거 시청 대비)'],
priority: 'P1',
},
{
channelId: 'kakaotalk', channelName: 'KakaoTalk', icon: 'messageSquare',
currentStatus: '상담 전용 운영', targetGoal: '상담 전환율 30% 향상',
contentTypes: ['상담 안내', '이벤트 알림', '예약 확인'],
postingFrequency: '주 1-2회 (메시지 발송)',
tone: '따뜻하고 전문적인 — 1:1 상담 톤',
formatGuidelines: ['자동 응답 + 상담사 연결 시스템', '시술별 상담 시나리오 준비', '예약 리마인더 자동 발송'],
priority: 'P1',
},
{
channelId: 'naver_cafe', channelName: 'Naver Cafe', icon: 'users',
currentStatus: '활성 — "뷰성형외과 성형의 모든것" 회원 5,984명, 비공개 카페', targetGoal: '회원 10,000명, 월 활성 참여율 20%',
contentTypes: ['시술별 Q&A 답변', '수술 전후 비교 콘텐츠', '회원 전용 이벤트', '원장 라이브 Q&A'],
postingFrequency: '주 3회 (Q&A 답변 + 후기 공유 + 이벤트)',
tone: '친밀하고 소통하는 — 커뮤니티 매니저 톤',
formatGuidelines: ['가입 신청 자동 승인 프로세스 개선', '시술별 Q&A 게시판 활성화 (가슴/윤곽/양악/눈/코)', '수술후기 작성 인센티브 (다음 시술 할인)', 'YouTube 영상 카페 내 공유로 조회수 상승'],
priority: 'P1',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{ title: '수술 전문성', description: '원장의 경험과 판단력을 보여주는 교육 콘텐츠', relatedUSP: 'Surgical Authority', exampleTopics: ['코성형 Q&A', '가슴보형물 선택 가이드', '양악수술 오해와 진실'], color: '#6C5CE7' },
{ title: '안전 & 신뢰', description: '21년 무사고 이력과 안전 시스템을 증명하는 콘텐츠', relatedUSP: 'Trust & Safety', exampleTopics: ['수술실 CCTV 공개', '마취 전문의 인터뷰', '회복 관리 시스템'], color: '#7A84D4' },
{ title: '결과 예측', description: '자연스러운 결과와 밸런스를 강조하는 비포/애프터', relatedUSP: 'Result Predictability', exampleTopics: ['자연스러운 코 라인', '얼굴 밸런스 분석', '과교정 방지 철학'], color: '#9B8AD4' },
{ title: '환자 여정', description: '상담부터 회복까지의 환자 경험을 보여주는 스토리텔링', relatedUSP: 'Patient Guidance', exampleTopics: ['상담 시뮬레이션', '수술 당일 브이로그', '회복 타임라인'], color: '#D4A872' },
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '주 1회', purpose: '깊은 신뢰 구축, 전문성 증명' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 5회', purpose: '도달 확대, 첫 관심 유도' },
{ format: 'Carousel', channels: ['Instagram KR'], frequency: '주 2회', purpose: '정보 전달, 저장 유도' },
{ format: 'Blog Post', channels: ['Naver Blog'], frequency: '주 3회', purpose: 'SEO 검색 유입, 키워드 확보' },
{ format: 'Stories', channels: ['Instagram KR', 'Instagram EN'], frequency: '일 2-3개', purpose: '일상 소통, 친밀감 형성' },
{ format: 'Ad Creative', channels: ['Facebook', 'Instagram'], frequency: '월 4-8개', purpose: '신규 환자 유입, 리타겟' },
],
workflow: [
{ step: 1, name: '주제 선정', description: '키워드 분석 + 콘텐츠 필러 매칭', owner: '마케팅 매니저', duration: '1일' },
{ step: 2, name: '원고 작성', description: 'AI 초안 생성 + 의료 검수', owner: 'AI + 의료진', duration: '1-2일' },
{ step: 3, name: '비주얼 제작', description: '촬영/영상 편집/디자인', owner: '콘텐츠 팀', duration: '2-3일' },
{ step: 4, name: '검토 & 승인', description: '원장 최종 검토 + 의료광고 규정 체크', owner: '원장 / 법무', duration: '1일' },
{ step: 5, name: '배포 & 모니터링', description: '채널별 최적 시간 게시 + 성과 추적', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '1개 원장 롱폼 영상 (10분)',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 풀 영상 업로드' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '핵심 구간 15-60초 클립 추출' },
{ format: 'Carousel 1-2개', channel: 'Instagram KR', description: '영상 내용을 카드뉴스로 재구성' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '영상 스크립트 → SEO 블로그 포스트 변환' },
{ format: 'Stories 3-5개', channel: 'Instagram', description: '비하인드 + 촬영 현장 스니펫' },
{ format: 'Ad Creative 2개', channel: 'Facebook / Instagram', description: '가장 임팩트 있는 장면 + CTA 오버레이' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: 브랜드 정비 & 첫 콘텐츠',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장 인터뷰: VIEW의 수술 철학' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: '프로필 리뉴얼 공지 + 첫 Reel' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 Q&A #1' },
{ dayOfWeek: 2, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '코성형 가이드: 내 얼굴에 맞는 코' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 가슴보형물 종류 비교' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 전후 Before/After #1' },
{ dayOfWeek: 4, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '가슴성형 절개 위치별 장단점' },
],
},
{
weekNumber: 2, label: 'Week 2: 콘텐츠 엔진 가동',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 안면윤곽' },
{ dayOfWeek: 0, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '안면윤곽 수술 종류와 회복기간' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 윤곽 전후 변화' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 사각턱 축소 과정' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 리프팅 시술 비교' },
{ dayOfWeek: 3, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 코성형 상담 유도 (리타겟)' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 눈성형 자연스러운 라인' },
{ dayOfWeek: 4, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '눈성형 쌍꺼풀 수술 FAQ' },
],
},
{
weekNumber: 3, label: 'Week 3: 신뢰 콘텐츠 강화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 수술 안전 시스템' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 수술실 안전 장비 소개' },
{ dayOfWeek: 1, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '성형외과 선택 시 확인할 안전 기준 5가지' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 마취 전문의가 함께합니다' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 21년 무사고의 비결' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 상담 전 꼭 알아야 할 것' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 안전 시스템 소개 (신규 유입)' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 최적화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '원장이 설명하는: 재수술 케이스' },
{ dayOfWeek: 0, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '재수술이 필요한 경우와 주의사항' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 재수술 전후 변화' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 원장 한 줄 답변 모음' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 상담 예약 가이드' },
{ dayOfWeek: 3, channel: 'Naver', channelIcon: 'globe', contentType: 'blog', title: '첫 성형 상담, 이것만 준비하세요' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 이 달의 베스트 케이스' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 월말 상담 예약 CTA' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상', count: 16, color: '#8B5CF6' },
{ type: 'blog', label: '블로그', count: 8, color: '#7A84D4' },
{ type: 'social', label: '소셜', count: 12, color: '#9B8AD4' },
{ type: 'ad', label: '광고', count: 4, color: '#D4A872' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '병원 내부 인테리어 사진', description: '로비, 상담실, 수술실 외관, 대기 공간 고화질 사진', repurposingSuggestions: ['Instagram Feed 배경', '유튜브 B-roll', 'Naver 블로그 대표 이미지'], status: 'collected' },
{ id: 'a2', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '의료진 프로필 사진', description: '28명 의료진 개인 프로필 사진 및 경력 정보', repurposingSuggestions: ['원장 소개 Carousel', '유튜브 섬네일', '네이버 블로그 프로필'], status: 'collected' },
{ id: 'a3', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 설명 텍스트', description: '가슴성형, 안면윤곽, 눈코 등 시술별 상세 설명', repurposingSuggestions: ['Naver 블로그 포스트 소스', 'Carousel 텍스트', '광고 카피'], status: 'collected' },
{ id: 'a4', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '기존 롱폼 영상 1,064개', description: '10년간 축적된 시술 설명, Q&A, 인터뷰 영상 아카이브', repurposingSuggestions: ['AI Shorts 추출 100+개', 'Instagram Reels 변환', 'TikTok 크로스포스팅'], status: 'collected' },
{ id: 'a5', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '고성과 Shorts (10만+ 조회)', description: '574K, 525K, 392K 조회 Shorts — 전후 변화 중심', repurposingSuggestions: ['Instagram Reels 재업로드', 'TikTok 동시 게시', '광고 소재 활용'], status: 'collected' },
{ id: 'a6', source: 'social', sourceLabel: '소셜미디어', type: 'photo', title: 'Instagram EN Before/After 사진', description: '@view_plastic_surgery 계정의 2,524개 게시물 중 B/A 사진', repurposingSuggestions: ['KR 계정 크로스포스팅', '유튜브 롱폼 삽입', 'Naver 블로그 활용'], status: 'collected' },
{ id: 'a7', source: 'social', sourceLabel: '소셜미디어', type: 'text', title: '강남언니 환자 리뷰 18,840건', description: '9.5점 평균, 시술별 실 환자 후기 텍스트', repurposingSuggestions: ['후기 기반 Carousel 시리즈', '블로그 환자 스토리', '광고 사회적 증거'], status: 'pending' },
{ id: 'a8', source: 'naver_place', sourceLabel: '네이버 플레이스', type: 'photo', title: '네이버 플레이스 사진', description: '병원 외관, 위치, 시설 사진', repurposingSuggestions: ['블로그 위치 안내 포스트', '구글 마이비즈니스 동기화'], status: 'pending' },
{ id: 'a9', source: 'blog', sourceLabel: '블로그', type: 'text', title: '네이버 블로그 기존 포스트 551개', description: '기존 블로그 포스트 551개 (월 2~3회 업데이트 중)', repurposingSuggestions: ['SEO 최적화 리라이팅', '영상 스크립트 소스'], status: 'collected' },
{ id: 'a10', source: 'homepage', sourceLabel: '홈페이지', type: 'video', title: '개원 20주년 기념 영상', description: '뷰성형외과 20년 역사 + 시설 소개 영상 (1:30)', repurposingSuggestions: ['브랜드 스토리 Reel', '웹사이트 히어로 영상', '신뢰 광고 소재'], status: 'collected' },
{ id: 'a11', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '시술별 전후 사진 갤러리', description: '눈, 코, 가슴, 윤곽 시술별 비포/애프터 사진', repurposingSuggestions: ['Instagram B/A 시리즈', 'Shorts 전환 소스', '상담 자료'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '한번에 성공하는 성형', views: 574000, type: 'Short', repurposeAs: ['Instagram Reel', 'TikTok', '광고 소재'] },
{ title: '코성형+지방이식 전후', views: 525000, type: 'Short', repurposeAs: ['Instagram Reel', 'TikTok', 'Naver 블로그 삽입'] },
{ title: '코성형! 내 얼굴에 가장 예쁜 코', views: 124000, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Carousel 3개', 'Blog Post 변환'] },
{ title: '아나운서 박은영, 가슴 할 결심', views: 127000, type: 'Long', repurposeAs: ['Shorts 3개 추출', '스토리 시리즈', '광고 소재'] },
{ title: '서울대 의학박사의 가슴재수술 성공전략', views: 1400, type: 'Long', repurposeAs: ['Shorts 추출', 'SEO 블로그', 'Carousel'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '한번에 성공하는 성형', views: 574000, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Instagram Reel', channel: 'Instagram KR', description: '자막 추가 + 한국어 해시태그 최적화 후 즉시 크로스포스팅' },
{ format: 'TikTok', channel: 'TikTok', description: '트렌딩 사운드 교체 + 텍스트 오버레이 재구성' },
{ format: '광고 소재', channel: 'Facebook / Instagram', description: '가장 임팩트 있는 3초 후크 장면 + CTA 오버레이' },
],
},
{
sourceVideo: { title: '코성형! 내 얼굴에 가장 예쁜 코', views: 124000, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개 추출', channel: 'YouTube', description: '핵심 설명 구간 15-60초 클립 5개 자동 추출' },
{ format: 'Carousel 3개', channel: 'Instagram KR', description: '코성형 타입별 비교 정보 카드뉴스로 재구성' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '영상 스크립트 → 2,000자 SEO 블로그 포스트 변환' },
{ format: 'Stories 시리즈', channel: 'Instagram', description: '촬영 비하인드 + Q&A 스니펫 5개' },
],
},
{
sourceVideo: { title: '아나운서 박은영, 가슴 할 결심', views: 127000, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'medium',
outputs: [
{ format: 'Shorts 3개 추출', channel: 'YouTube / Instagram / TikTok', description: '스토리 하이라이트 구간 크로스포스팅' },
{ format: '스토리 시리즈', channel: 'Instagram KR', description: '상담 결정 과정 + 회복 타임라인 Stories' },
{ format: '광고 소재', channel: 'Facebook', description: '환자 신뢰도 강화 소셜 프루프 광고 소재' },
],
},
{
sourceVideo: { title: '코성형+지방이식 전후', views: 525000, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Instagram Reel', channel: 'Instagram KR', description: 'Before/After 포맷 최적화 + 동의서 확인 후 게시' },
{ format: 'TikTok', channel: 'TikTok', description: '트렌드 사운드 교체 + Stitch 유도 CTA 추가' },
{ format: 'Naver 블로그 삽입', channel: 'Naver Blog', description: '코+지방이식 복합 시술 블로그 포스트에 영상 임베드' },
],
},
],
workflow: {
items: [
{
id: 'wf-001',
title: '코성형 전후 비교 YouTube Shorts',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"코가 달라지면 인상이 달라져요."\n(전 사진 → 후 사진 전환)\n\n[본문 — 3~25초]\n"VIEW 성형외과의 코성형, 단순히 높이는 것이 아닙니다."\n"얼굴 전체 비율을 분석한 맞춤형 디자인,"\n"21년 무사고 기록이 말해줍니다."\n(수술 과정 그래픽 삽입)\n\n[CTA — 25~30초]\n"지금 상담 예약 — 링크 프로필 참고"`,
shootingGuide: [
'전/후 고화질 사진 세로 4:5 비율로 준비',
'자연광 또는 소프트박스 조명에서 정면 + 3/4 앵글 촬영',
'배경: 클리닉 로고 배경 또는 화이트 배경',
'의사 얼굴 없이 사진 위주 편집 (환자 동의서 필수)',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '가슴성형 궁금증 5가지 — Instagram Reel',
contentType: 'video',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
userNotes: '마지막 슬라이드에 전화번호 대신 카카오톡 채널명으로 바꿔주세요',
videoDraft: {
script: `[후크 — 0~2초]\n"가슴성형 전에 꼭 알아야 할 5가지"\n\n[1] 보형물 종류: 라운드 vs 물방울\n[2] 절개 위치 선택법\n[3] 회복 기간 현실 (3일~2주)\n[4] 재수술률을 낮추는 병원 고르는 기준\n[5] VIEW 21년 무사고의 비결\n\n[CTA]\n"자세한 상담은 카카오채널 \'뷰성형외과의원\'"`,
shootingGuide: [
'텍스트 슬라이드 5장 제작 (배경: #7B2D8E 그라디언트)',
'각 슬라이드에 VIEW 로고 워터마크 우측 하단 배치',
'트랜지션: 빠른 슬라이드 컷 (0.2초)',
'배경음악: 경쾌한 팝 인스트루멘탈 (저작권 무료)',
],
duration: '45초',
},
},
{
id: 'wf-003',
title: '코성형 Q&A 카드뉴스 — Naver 블로그',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'cardnews',
headline: '코성형, 궁금한 게 너무 많죠? 전문의가 직접 답합니다',
copy: [
'[카드 1] 코성형 후 붓기는 얼마나 지속되나요?\n→ 초기 붓기 1~2주, 완전 회복 3~6개월. 일상 복귀는 보통 1주일.',
'[카드 2] 실리콘 vs 연골, 어떤 재료가 좋나요?\n→ 높이와 형태 교정에 따라 다름. VIEW는 개인 맞춤형 복합 재료 활용.',
'[카드 3] 코성형 후 운동은 언제부터?\n→ 가벼운 걷기: 1주 후 / 격렬한 운동: 최소 4주 후.',
'[카드 4] VIEW 코성형이 다른 이유\n→ 21년 무사고 · 전담 의료진 · 3D 시뮬레이션 상담 제공.',
],
layoutHint: '4장 카드 세로형, 보라+골드 브랜드 컬러, 마지막 카드에 CTA (상담 예약 버튼)',
},
},
{
id: 'wf-004',
title: '눈성형 회복기 솔직 후기 블로그 포스트',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'approved',
scheduledDate: '2026-05-06',
imageTextDraft: {
type: 'blog',
headline: '눈성형 2주 후 솔직 리뷰 — VIEW 성형외과 후기',
copy: [
'수술 당일: 2시간 소요, 국소마취. 통증 최소화 확인.',
'수술 다음날: 붓기 있지만 일상생활 가능 수준.',
'1주일 후: 자연스러운 라인 확인. 실밥 제거.',
'2주일 후: 친구들도 "뭔가 달라졌는데?" 반응.',
'VIEW의 장점: 의사 선생님이 수술 전 충분히 상담해주셔서 기대치 조정이 잘 됐습니다.',
],
layoutHint: '1200px 썸네일 + 본문 2000자 이상, 키워드: 눈성형 후기, 강남 눈성형, VIEW 성형외과',
},
},
{
id: 'wf-005',
title: '21년 무사고 스토리 TikTok',
contentType: 'video',
channel: 'TikTok',
channelIcon: 'video',
stage: 'scheduled',
scheduledDate: '2026-05-02',
videoDraft: {
script: `"21년 동안 단 한 건의 의료사고도 없었습니다."\n(숫자 카운터 애니메이션: 0 → 21)\n"이게 VIEW의 자랑입니다."\n#강남성형외과 #무사고 #VIEW성형외과`,
shootingGuide: [
'텍스트 애니메이션 위주 편집 (After Effects 또는 CapCut)',
'배경: 클리닉 내부 실사 영상 블러 처리',
'폰트: VIEW 브랜드 서체 일치',
],
duration: '15초',
},
},
],
},
};

View File

@ -0,0 +1,517 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
*
*
* (`mockReport_banobagi.ts` 2026-04-14 ):
* - YouTube @banobagips: 13K , 925 ( 6 )
* - Instagram @banobagi_ps: 4,183 , 2,000 (Reels 10 )
* - Facebook @BanobagiPlasticSurgery: 16K (EN KR )
* - @banobagips: 2023-04-21 (2 )
* - : 9.2 / 6,853 ( )
* - 26 (2000 ), , + 6+5
* - : / / / / / ·
*
* · . mockPlan(View) .
*/
export const mockPlanBanobagi: MarketingPlan = {
id: 'banobagi',
reportId: 'banobagi',
clinicName: '바노바기성형외과의원',
clinicNameEn: 'Banobagi Plastic Surgery Clinic',
createdAt: '2026-04-14',
targetUrl: 'https://www.banobagi.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'Banobagi Black', hex: '#1A1A1A', usage: '공식 로고 메인, 헤딩, 강조 텍스트' },
{ name: 'Banobagi Gold', hex: '#C8A96A', usage: '로고 악센트, CTA 포인트, 구분선' },
{ name: 'Ivory White', hex: '#FAF7F2', usage: '배경, 카드, 여백' },
{ name: 'Deep Charcoal', hex: '#2D2D2D', usage: '본문 텍스트' },
{ name: 'Soft Gray', hex: '#6B6B6B', usage: '서브 텍스트, 메타 정보' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀', sampleText: '26년 강남 대표 성형외과' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트', sampleText: '바노바기성형외과 — 안전과 자연스러움' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩', sampleText: 'BANOBAGI' },
],
logoRules: [
{ rule: '블랙+골드 워드마크 통일 사용', description: '공식 BANOBAGI 워드마크(블랙 배경 + 골드 텍스트)를 모든 채널에서 동일하게 사용', correct: true },
{ rule: '원형 로고: 블랙 배경 골드 심볼 버전', description: '프로필 사진용 원형 버전은 블랙 원형 + 골드 "B" 심볼 1080×1080 사용', correct: true },
{ rule: '가로형 로고: 워드마크 + 한글 병기', description: '배너·헤더에는 BANOBAGI 워드마크 + "바노바기성형외과" 한글 병기', correct: true },
{ rule: '저해상도 프로필 사진 금지', description: '채널별 프로필 해상도 편차 발견 — 1080×1080 단일 원형 로고로 통일 필요', correct: false },
{ rule: '한글 채널 설명 우선', description: 'YouTube 채널 설명이 영문 위주 — 한국 시장 타겟팅 위해 한글 우선/영문 병기', correct: false },
{ rule: '로고 주변 여백 확보', description: '로고 크기의 30% 이상 여백을 유지하여 가독성 확보', correct: true },
],
toneOfVoice: {
personality: ['신뢰감 있는', '전문적', '차분한', '결과 중심', '절제된 럭셔리'],
communicationStyle: '26년 축적된 임상 경험을 바탕으로, 과장 없이 결과와 원칙으로 설득합니다. 환자의 관점에서 생각하되 의료 전문성을 잃지 않는 톤.',
doExamples: [
'"자연스러움이 보일 때까지, 디테일에 디테일을 더합니다"',
'"26년 임상 경험, 6,853개의 진솔한 후기"',
'"바노바기의 원칙: 결과로 증명합니다"',
'"분야별 전문의 공동 진료 시스템"',
],
dontExamples: [
'"강남 최고! 파격 할인!"',
'"연예인이 선택한 병원"',
'"100% 만족 보장"',
'"이 가격은 오늘까지만!"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: '블랙+골드 BANOBAGI 원형 로고 1080×1080', bannerSpec: '2560×1440px, 블랙 배경 + 골드 워드마크, "26년 강남 대표 성형외과" 슬로건', bioTemplate: '26년 강남 대표 성형외과 — 바노바기\n안면윤곽·눈·코·가슴·리프팅\n02-1588-6508 | banobagi.com', currentStatus: 'incorrect' },
{ channel: 'Instagram KR', icon: 'instagram', profilePhoto: '블랙+골드 원형 로고', bannerSpec: 'N/A (하이라이트 커버: 블랙+골드 아이콘 세트 — 안면윤곽/눈/코/가슴/리뷰)', bioTemplate: '바노바기성형외과 공식 — 26년 임상\n역삼역·강남역 | 02-1588-6508\nbanobagi.com', currentStatus: 'incorrect' },
{ channel: 'Facebook KR', icon: 'facebook', profilePhoto: '블랙+골드 원형 로고', bannerSpec: '820×312px, 블랙+골드 배너 + "26년 강남 대표 성형외과"', bioTemplate: '바노바기성형외과 한국 공식 — 26년 임상 경험', currentStatus: 'missing' },
{ channel: 'Facebook EN', icon: 'facebook', profilePhoto: '블랙+골드 원형 로고', bannerSpec: '820×312px, 동일 디자인 시스템', bioTemplate: 'Banobagi Plastic Surgery — 26 Years of Excellence in Gangnam', currentStatus: 'correct' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: '블랙+골드 로고', bannerSpec: '블로그 상단: 워드마크 + 시술 카테고리 메뉴', bioTemplate: '바노바기성형외과 공식 블로그 — 26년 강남 대표\n안면윤곽·눈·코·가슴·리프팅·지방체형', currentStatus: 'incorrect' },
{ channel: 'TikTok', icon: 'video', profilePhoto: '블랙+골드 원형 로고', bannerSpec: 'N/A', bioTemplate: '바노바기성형외과 — 26년 강남 대표\n역삼역 | 02-1588-6508', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '로고',
values: [
{ channel: 'YouTube', value: 'BANOBAGI 영문 워드마크', isCorrect: true },
{ channel: 'Instagram KR', value: '바노바기 블랙+골드 로고', isCorrect: true },
{ channel: 'Facebook EN', value: '블랙+골드 워드마크', isCorrect: true },
{ channel: 'Website', value: '블랙+골드 공식 로고', isCorrect: true },
],
impact: '브랜드 시각 아이덴티티는 비교적 일관되지만 채널별 프로필 해상도 편차 존재. 한국 시장용 Facebook KR 페이지와 TikTok 채널 자산 부재.',
recommendation: '전 채널 1080×1080 원형 로고 재발급 + Facebook KR / TikTok 신규 채널 자산 제작',
},
{
field: '바이오/소개 메시지',
values: [
{ channel: 'YouTube', value: 'Protect and nurture your true beauty… (영문 위주)', isCorrect: false },
{ channel: 'Instagram KR', value: '바노바기성형외과 공식 계정 (USP 부재)', isCorrect: false },
{ channel: 'Facebook EN', value: 'Korean Premium Plastic Surgery Clinic', isCorrect: false },
{ channel: 'Naver Blog', value: '(설명 부실 — 2년 방치)', isCorrect: false },
],
impact: '4개 채널, 4개의 서로 다른 소개 — "26년 임상" 핵심 USP가 어느 곳에도 부각되지 않음',
recommendation: '핵심 USP 통일: "26년 강남 대표 성형외과 — 바노바기" + 시술군 + 연락처 표준 포맷',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '13K 구독자, 925 영상 누적 — 최근 6개월 업로드 저조',
targetGoal: '30K 구독자 / 12개월, 주 2회 정기 업로드 복구',
contentTypes: ['Shorts (기존 925 영상 재편집)', 'Long-form (원장 인터뷰·시술 가이드)', 'Community (Q&A 투표)'],
postingFrequency: '주 2회 (Shorts 1 + 롱폼 격주)',
tone: '차분한 전문가 — 반재중 원장 직접 설명 교육 콘텐츠',
formatGuidelines: [
'Shorts: 925개 기존 영상에서 핵심 15-60초 구간 추출, 후크 3초 내',
'Long-form: 5-12분, 안면윤곽/가슴 등 시술별 시리즈',
'썸네일: 블랙+골드 워드마크 워터마크 통일',
'한글 타이틀 + 영문 부제 병기 (한국 시장 타겟팅 강화)',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram_kr', channelName: 'Instagram', icon: 'instagram',
currentStatus: '@banobagi_ps — 4,183 팔로워, 2,000 게시물, Reels 10개 (부족)',
targetGoal: '60K 팔로워 / 12개월, Reels 주 5개',
contentTypes: ['Reels (YouTube Shorts 동시 배포)', 'Carousel (강남언니 6,853 리뷰 스토리화)', 'Stories (병원 일상)', 'Before/After'],
postingFrequency: '일 1회 + Stories 일 2-3개',
tone: '차분하지만 친근한 — 26년 신뢰감 + 환자 관점 Q&A',
formatGuidelines: [
'Reels: YouTube Shorts 와 동시 게시 (자산 재활용 우선)',
'Carousel: 강남언니 리뷰 50개 선별 → 카루셀 콘텐츠 시리즈',
'Stories: 안면윤곽/눈/코/가슴 카테고리별 하이라이트 재구성',
'해시태그: #바노바기성형외과 #Banobagi #강남안면윤곽 #26년임상',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'facebook_en', channelName: 'Facebook EN (국제)', icon: 'facebook',
currentStatus: '@BanobagiPlasticSurgery — 16K 팔로워, 영문 위주, 리타겟 채널로 활용 가능',
targetGoal: '의료관광 리타겟 광고 채널로 전환, 광고 ROAS 추적 시작',
contentTypes: ['영문 환자 여정 콘텐츠', 'Before/After (동의서 확인)', '리타겟 광고 소재'],
postingFrequency: '주 1-2회 + 광고 캠페인 상시',
tone: 'Professional & warm — Korea medical tourism storytelling',
formatGuidelines: [
'Pixel 기반 리타겟 광고 (이미 설치 확인됨)',
'WhatsApp 연동 활용 — 해외 상담 자동 응대',
'영문 환자 후기 인터뷰 시리즈',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
{
channelId: 'facebook_kr', channelName: 'Facebook KR (신규)', icon: 'facebook',
currentStatus: '한국 전용 페이지 부재 — 한국 환자 접근성 0',
targetGoal: '한국 페이지 신규 개설 또는 Instagram KR 집중 결정 (3개월 내)',
contentTypes: ['(개설 시) Reels 크로스포스팅', '광고 리타겟'],
postingFrequency: '주 2-3회 (개설 시)',
tone: '한국 환자 친화적 — 신뢰 기반',
formatGuidelines: [
'한국 KR 페이지 신규 개설 vs Instagram KR 집중 ROI 비교 후 결정',
'개설 시: Instagram Reels 와 동일 콘텐츠 크로스포스팅',
],
priority: 'P2',
customerJourneyStage: 'awareness',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '@banobagips — 마지막 포스트 2023-04-21 (2년 이상 방치, SEO 자산 잠금)',
targetGoal: '주 2회 포스팅 즉시 재가동, 12개월 내 월 20,000 방문자',
contentTypes: ['SEO 시술 가이드', '환자 후기 (강남언니 리뷰 가공)', 'YouTube 영상 임베드 + 텍스트 가이드'],
postingFrequency: '주 2회 (1차 6월 내 시작)',
tone: '정보성 전문가 — "바노바기 후기/안면윤곽/V라인" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 최적화 포스트',
'YouTube 시술 영상 + 텍스트 가이드 결합 구조',
'SEO 키워드 맵: "바노바기 후기", "강남 안면윤곽", "사각턱 축소", "V라인"',
'이미지 10장 이상 + 영상 임베드 1개 이상',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.2점/10, 6,853 리뷰 — 강남 상위권, 핵심 자산',
targetGoal: '리뷰 응답률 80% 달성, 12개월 내 8,500 리뷰',
contentTypes: ['리뷰 응답', '시술 정보 업데이트', '이벤트/프로모션 게시'],
postingFrequency: '리뷰 응답: 일 단위 / 정보 업데이트: 월 1-2회',
tone: '진심 어린 의료진 답변 — 형식적 답변 지양',
formatGuidelines: [
'리뷰 응답률 50%(3개월) → 80%(12개월) 단계 상향',
'6,853 리뷰 중 50개 선별 → SNS 콘텐츠로 재가공',
'부정 리뷰 24시간 내 응답 원칙',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
{
channelId: 'tiktok', channelName: 'TikTok (신규)', icon: 'video',
currentStatus: '계정 없음',
targetGoal: '10K 팔로워 / 12개월, 20-30대 첫 수술 고민층 도달',
contentTypes: ['YouTube Shorts 크로스포스팅', '트렌드 챌린지', '병원 비하인드'],
postingFrequency: '주 5회 (YouTube Shorts 동시 배포)',
tone: '가볍고 접근 가능한 — 20-30대 타겟',
formatGuidelines: [
'925 YouTube 영상 → Shorts 추출 → TikTok 동시 배포',
'트렌딩 사운드 활용 + 자막 필수',
'의료광고법 준수 — 과장 표현 금지',
],
priority: 'P1',
customerJourneyStage: 'awareness',
},
{
channelId: 'kakaotalk', channelName: 'KakaoTalk', icon: 'messageSquare',
currentStatus: '02-1588-6508 대표번호 + 카카오 채널 운영',
targetGoal: '카카오 채널 친구 수 확보, 상담 전환율 30% 향상',
contentTypes: ['상담 안내', '예약 확인', '월 1회 시술 정보 발송'],
postingFrequency: '주 1-2회 메시지 발송',
tone: '따뜻하고 전문적인 — 1:1 상담 톤',
formatGuidelines: [
'자동 응답 시나리오 — 안면윤곽/가슴/눈/코 시술별 분기',
'예약 리마인더 자동 발송',
'한국어 + 영문/중문 옵션 (의료관광 대비)',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '26년 임상의 원칙',
description: '2000년 개원 이래 26년간 축적한 임상 데이터와 수술 철학을 보여주는 콘텐츠',
relatedUSP: '26 Years of Clinical Experience',
exampleTopics: ['반재중 원장의 안면윤곽 철학', '26년간 변하지 않은 원칙 3가지', '바노바기의 분야별 공동 진료 시스템'],
color: '#1A1A1A',
},
{
title: '안면윤곽 전문성',
description: '바노바기의 핵심 USP인 안면윤곽 분야의 깊이 있는 교육·기술 콘텐츠',
relatedUSP: 'Facial Contouring Authority',
exampleTopics: ['사각턱 축소 — 골격별 맞춤 디자인', 'V라인 자연스러움의 기준', '안면윤곽 후 회복 타임라인'],
color: '#C8A96A',
},
{
title: '6,853 리뷰의 진심',
description: '강남언니 6,853 리뷰를 환자의 목소리로 재구성, 진솔한 후기 콘텐츠',
relatedUSP: 'Patient-Validated Trust',
exampleTopics: ['실제 환자 인터뷰 시리즈', '강남언니 베스트 리뷰 카루셀', '수술 결정부터 회복까지 환자 다이어리'],
color: '#6B6B6B',
},
{
title: '안전 시스템',
description: '마취과 전문의 상주, 분야별 공동 진료 등 바노바기의 안전 인프라',
relatedUSP: 'Safety Infrastructure',
exampleTopics: ['마취과 전문의가 상주하는 이유', '본관+별관 시설 투어', '시술 후 관리 프로토콜'],
color: '#FAF7F2',
},
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '격주 1회', purpose: '26년 임상 전문성 증명, 깊은 신뢰 구축' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 5개', purpose: '925 영상 자산 재활용, 도달 확대' },
{ format: 'Carousel (리뷰 스토리)', channels: ['Instagram'], frequency: '주 2회', purpose: '강남언니 6,853 리뷰 → 사회적 증거화' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 2회', purpose: '2년 방치된 SEO 자산 복구, 검색 유입' },
{ format: 'Stories', channels: ['Instagram'], frequency: '일 2-3개', purpose: '병원 일상·상담 비하인드' },
{ format: 'Ad Creative (EN)', channels: ['Facebook EN', 'Instagram'], frequency: '월 4-6개', purpose: '의료관광 리타겟, Pixel 기반 전환' },
],
workflow: [
{ step: 1, name: '기존 자산 인벤토리', description: '925 YouTube 영상 + 2,000 IG 게시물 + 6,853 강남언니 리뷰 카탈로그화', owner: '마케팅 매니저', duration: '1-2주 (1회성)' },
{ step: 2, name: '주제 선정', description: '시술별 검색 키워드 + 콘텐츠 필러 매칭', owner: '마케팅 매니저', duration: '1일' },
{ step: 3, name: '초안 작성/추출', description: 'AI 초안 생성 또는 기존 영상에서 Shorts 추출', owner: 'AI + 콘텐츠 팀', duration: '1-2일' },
{ step: 4, name: '의료 검수', description: '반재중 원장 + 분야별 전문의 검토 (의료광고법 포함)', owner: '의료진', duration: '1일' },
{ step: 5, name: '비주얼 마감', description: '블랙+골드 워터마크, 한글/영문 자막 추가', owner: '디자인 팀', duration: '1일' },
{ step: 6, name: '배포 & 모니터링', description: '채널별 최적 시간 게시 + UTM 추적', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '1개 원장 롱폼 영상 (10분) 또는 925 영상 아카이브 1편',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 풀 영상 업로드 (한글/영문 자막)' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '핵심 구간 15-60초 추출, 3채널 동시 배포' },
{ format: 'Carousel 1-2개', channel: 'Instagram', description: '시술 가이드 카드뉴스로 재구성' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '영상 스크립트 → 2,000자 SEO 포스트 (영상 임베드 포함)' },
{ format: 'Stories 3-5개', channel: 'Instagram', description: '촬영 비하인드 + Q&A 스니펫' },
{ format: 'Ad Creative 2개', channel: 'Facebook EN / Instagram', description: '의료관광 리타겟용 영문 광고 소재' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: 브랜드 정비 & 블로그 재가동',
entries: [
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '재가동 1편: 26년 바노바기, 다시 시작합니다 — 시술 카테고리 가이드' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: '프로필 리뉴얼 공지 + 첫 Reel (블랙+골드 통일)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 안면윤곽 Q&A — 사각턱 축소 #1' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '안면윤곽 수술 종류와 회복기간 — 26년 임상 기준' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 6,853 리뷰 중 베스트 5' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 반재중 원장 — 바노바기의 26년 원칙' },
{ dayOfWeek: 5, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 본관+별관 시설 투어' },
],
},
{
weekNumber: 2, label: 'Week 2: 안면윤곽 집중 주간',
entries: [
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: 'V라인의 자연스러움 기준 — 골격별 맞춤 디자인' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 안면윤곽 전후 변화 (동의 환자)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 사각턱 축소 과정 30초' },
{ dayOfWeek: 2, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 안면윤곽 후 회복 타임라인' },
{ dayOfWeek: 3, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(EN): 의료관광 안면윤곽 인콰이어리 (리타겟)' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 환자 후기 인터뷰 #1' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '바노바기 안면윤곽 후기 — 강남언니 검증' },
],
},
{
weekNumber: 3, label: 'Week 3: 가슴·눈·코 시술 콘텐츠',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 가슴성형 — 보형물 종류와 선택 기준' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 코성형 자연스러운 라인' },
{ dayOfWeek: 1, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '눈성형 쌍꺼풀 수술 FAQ — 26년 임상 답변' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 가슴성형 회복 1주 차' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 눈성형 4가지 디자인 비교' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 마취과 전문의가 상주합니다' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '바노바기 안전 시스템 — 마취 전문의 상주의 의미' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 & 리뷰 응답 강화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 분야별 공동 진료 시스템 — 4명 전문의의 협진' },
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '첫 성형 상담, 이것만 준비하세요 — 바노바기 가이드' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 상담 비하인드 — 3D 시뮬레이션 데모' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 반재중 원장 한 줄 답변 모음' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 카카오 상담 예약 4단계' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '강남언니 9.2점, 6,853 리뷰의 의미 — 환자가 검증한 26년' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(EN): 월말 의료관광 상담 CTA' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 14, color: '#1A1A1A' },
{ type: 'blog', label: '블로그', count: 8, color: '#C8A96A' },
{ type: 'social', label: 'Instagram', count: 9, color: '#6B6B6B' },
{ type: 'ad', label: '광고', count: 2, color: '#FAF7F2' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '925 영상 아카이브 (13년 누적)', description: '2013년 개설 이래 누적 925개 영상 — Shorts 추출 최우선 자산', repurposingSuggestions: ['Shorts 200개 추출 (P0)', 'Instagram Reels 변환', 'TikTok 크로스포스팅', '블로그 포스트 임베드'], status: 'collected' },
{ id: 'a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 환자 리뷰 6,853건', description: '9.2점/10 평균, 시술별 실 환자 후기 — 사회적 증거 핵심 자산', repurposingSuggestions: ['후기 50개 선별 → Carousel 시리즈', 'Instagram Stories 시리즈', '광고 소셜프루프', 'Blog 환자 스토리'], status: 'pending' },
{ id: 'a3', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '본관+별관 시설 사진', description: '본관 6층 + 별관 5층 외관/내부 인테리어 고화질 사진', repurposingSuggestions: ['Instagram 시설 투어 Reel', '유튜브 B-roll', '블로그 위치 안내'], status: 'collected' },
{ id: 'a4', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '의료진 4명 프로필', description: '반재중 대표원장 + 분야별 전문의 4명 프로필 사진·경력', repurposingSuggestions: ['원장 소개 Carousel', '유튜브 인터뷰 섬네일', '블로그 의료진 페이지'], status: 'collected' },
{ id: 'a5', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 카테고리 6종 설명', description: '안면윤곽/눈/코/가슴/지방체형/리프팅 시술별 상세 설명', repurposingSuggestions: ['블로그 SEO 포스트 소스', 'Carousel 텍스트', '광고 카피'], status: 'collected' },
{ id: 'a6', source: 'social', sourceLabel: 'Instagram', type: 'photo', title: 'Instagram 게시물 2,000개', description: '@banobagi_ps 누적 2,000 게시물 (B/A · 카드뉴스 혼합)', repurposingSuggestions: ['고성과 게시물 → Reel 변환', 'Carousel 재편집', '광고 소재 추출'], status: 'collected' },
{ id: 'a7', source: 'blog', sourceLabel: '네이버 블로그', type: 'text', title: '기존 블로그 포스트 (2023-04 이전)', description: '2년 방치 전 포스트 — SEO 키워드 분석 후 리라이팅 대상', repurposingSuggestions: ['고트래픽 포스트 리라이팅', '키워드 맵 추출', '내부 링크 재구성'], status: 'pending' },
{ id: 'a8', source: 'naver_place', sourceLabel: '네이버 플레이스', type: 'photo', title: '네이버 플레이스 사진/리뷰', description: '플레이스 등록 사진 + 리뷰 (응답률 최적화 대상)', repurposingSuggestions: ['리뷰 응답 시스템화', '플레이스 사진 업데이트', '구글 마이비즈니스 동기화'], status: 'pending' },
{ id: 'a9', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '시술별 전후 사진 갤러리', description: '안면윤곽/눈/코/가슴 시술별 비포/애프터 (동의서 보유 분)', repurposingSuggestions: ['Instagram B/A 시리즈', 'Shorts 전환 소스', '상담 자료'], status: 'collected' },
{ id: 'a10', source: 'homepage', sourceLabel: '홈페이지', type: 'video', title: '병원 소개 영상 (제작 필요)', description: '26년 역사 + 시설 + 의료진 통합 브랜드 영상 — 신규 제작', repurposingSuggestions: ['브랜드 스토리 Reel', '웹사이트 히어로 영상', 'YouTube 채널 트레일러'], status: 'needs_creation' },
{ id: 'a11', source: 'social', sourceLabel: '의료관광', type: 'video', title: '영문 환자 인터뷰 (제작 필요)', description: '의료관광 환자 영문 인터뷰 시리즈 — Facebook EN 16K 활성화용', repurposingSuggestions: ['Facebook EN 시리즈', 'Instagram EN Reels', 'WhatsApp 상담 자료'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '바노바기 안면윤곽 재생목록 베스트', views: 0, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Instagram Carousel', 'Blog 임베드'] },
{ title: '눈성형 시술 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 3개 추출', 'Reels 동시 배포', 'Blog 변환'] },
{ title: '코성형 시술 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 3개 추출', 'TikTok 크로스포스팅', 'Carousel'] },
{ title: '가슴성형 시술 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Instagram Reel', 'Blog 변환'] },
{ title: '지방체형 시술 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Reel 시리즈'] },
{ title: '리프팅·동안성형 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Carousel', 'Blog'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '925 영상 → 안면윤곽 베스트 추출', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'high',
priority: 'high',
outputs: [
{ format: 'Shorts 50개 (1차)', channel: 'YouTube / Instagram / TikTok', description: '925 영상 중 안면윤곽 카테고리 우선 추출 — 3개월 분량 확보' },
{ format: 'Carousel 10개', channel: 'Instagram', description: '시술 단계·회복 타임라인 카드뉴스화' },
{ format: 'Blog Post 5개', channel: 'Naver Blog', description: '영상 스크립트 → SEO 포스트 변환 (재가동 콘텐츠)' },
],
},
{
sourceVideo: { title: '강남언니 6,853 리뷰 → 콘텐츠화', views: 6853, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Carousel 시리즈 20개', channel: 'Instagram', description: '시술별 베스트 리뷰 카드뉴스 — 익명화 + 동의 확인' },
{ format: 'Stories 시리즈', channel: 'Instagram', description: '환자 다이어리 형식 Stories' },
{ format: '광고 소셜프루프', channel: 'Facebook EN / Instagram', description: '"6,853 리뷰의 진심" 메시지 광고 소재' },
{ format: 'Blog 환자 스토리', channel: 'Naver Blog', description: '리뷰 → 환자 여정 스토리 블로그 변환' },
],
},
{
sourceVideo: { title: '반재중 원장 인터뷰 (롱폼 신규 제작)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개 추출', channel: 'YouTube / TikTok', description: '"바노바기의 26년 원칙" 핵심 구간 클립' },
{ format: 'Carousel', channel: 'Instagram', description: '원장 철학 카드뉴스' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '인터뷰 풀 스크립트 → 2,500자 SEO 포스트' },
{ format: '광고 소재', channel: 'Facebook EN', description: '"26 Years of Excellence" 영문 광고' },
],
},
{
sourceVideo: { title: '본관+별관 시설 투어 (신규 제작)', views: 0, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'medium',
outputs: [
{ format: 'Instagram Reel', channel: 'Instagram', description: '시설 투어 60초 Reel' },
{ format: 'TikTok', channel: 'TikTok', description: '동일 영상 TikTok 동시 배포' },
{ format: 'Stories 시리즈', channel: 'Instagram', description: '구역별 Stories (대기실/상담실/수술실 외관)' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'wf-001',
title: '안면윤곽 사각턱 축소 Shorts (925 영상 추출)',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"사각턱, 깎는다고 다 V라인 되는 게 아닙니다."\n(전 사진 → 후 사진 전환)\n\n[본문 — 3~25초]\n"바노바기는 골격 분석부터 시작합니다."\n"턱 끝, 광대, 옆모습 비율을 함께 보고"\n"26년 임상 기준으로 디자인합니다."\n(수술 과정 그래픽 삽입)\n\n[CTA — 25~30초]\n"강남언니 6,853 리뷰 — 프로필 링크 확인"`,
shootingGuide: [
'925 영상 아카이브에서 안면윤곽 카테고리 베스트 컷 추출',
'블랙+골드 워드마크 우측 하단 워터마크 통일',
'한글 자막 필수 + 영문 자막 옵션',
'환자 동의서 확인 후 비식별화 처리',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '강남언니 베스트 리뷰 5선 Carousel',
contentType: 'image-text',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
userNotes: '리뷰 인용 시 환자 동의 절차 다시 확인 부탁드립니다',
imageTextDraft: {
type: 'cardnews',
headline: '강남언니 6,853 리뷰 중 — 진심이 담긴 5개',
copy: [
'[카드 1] "26년 임상이 어떤 의미인지 상담받고 알았어요" — 안면윤곽 환자',
'[카드 2] "마취 전문의가 상주한다는 게 이렇게 든든할 줄 몰랐습니다" — 가슴 환자',
'[카드 3] "수술 후 케어가 오래 이어져서 안심했어요" — 눈 환자',
'[카드 4] "분야별 전문의 협진이 결과의 디테일을 만든다" — 코 환자',
'[카드 5] 9.2점 / 6,853 리뷰 — 강남언니에서 직접 확인하세요',
],
layoutHint: '5장 카드 세로형, 블랙+골드 컬러, 마지막 카드에 강남언니 링크 CTA',
},
},
{
id: 'wf-003',
title: '네이버 블로그 재가동 1편 — 26년 인사',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '바노바기성형외과 블로그, 다시 시작합니다 — 26년의 약속',
copy: [
'2년간 멈춰 있던 블로그를 다시 엽니다.',
'바노바기는 2000년부터 26년간 강남에서 한 자리를 지켰습니다.',
'안면윤곽·눈·코·가슴·지방체형·리프팅 6개 카테고리 가이드를 매주 두 번씩 올립니다.',
'의료진 4명 — 분야별 공동 진료 시스템을 이 블로그에서 자세히 소개할 예정입니다.',
'본관 6층 + 별관 5층의 시설 투어, 마취과 전문의 상주 시스템도 곧 다룹니다.',
'강남언니에 쌓인 6,853 리뷰의 진심을 — 이 블로그에서 더 자세히 풀어드리겠습니다.',
],
layoutHint: '1200px 썸네일 + 본문 2,000자 이상, 키워드: 바노바기성형외과, 강남 안면윤곽, 26년 임상',
},
},
{
id: 'wf-004',
title: '반재중 원장 26년 원칙 — YouTube 롱폼',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'approved',
scheduledDate: '2026-04-21',
videoDraft: {
script: `[오프닝]\n"안녕하세요, 바노바기성형외과 반재중입니다."\n"오늘은 26년간 변하지 않은 3가지 원칙을 말씀드립니다."\n\n[원칙 1: 골격 분석]\n"디자인은 사진이 아니라 골격에서 시작합니다."\n\n[원칙 2: 분야별 협진]\n"한 명의 의사가 모든 것을 결정하지 않습니다."\n\n[원칙 3: 회복까지의 책임]\n"수술이 끝이 아니라, 회복이 끝입니다."\n\n[클로징]\n"26년, 6,853 리뷰가 증명합니다. 바노바기성형외과."`,
shootingGuide: [
'본관 상담실 자연광 촬영',
'원장 정면 + 3/4 앵글 2채널',
'시설 B-roll: 본관/별관 외관, 수술실 입구, 마취 장비',
'한글 자막 + 영문 자막 (의료관광 대비)',
'블랙+골드 인트로/아웃트로 6초 통일',
],
duration: '8분',
},
},
{
id: 'wf-005',
title: '본관+별관 시설 투어 TikTok',
contentType: 'video',
channel: 'TikTok',
channelIcon: 'video',
stage: 'scheduled',
scheduledDate: '2026-04-18',
videoDraft: {
script: `"강남 한가운데 본관 6층 + 별관 5층."\n(시설 점프컷)\n"마취과 전문의가 상주합니다."\n"분야별 전문의 4명이 협진합니다."\n"2000년부터 26년 — 바노바기성형외과."\n#강남성형외과 #안면윤곽 #바노바기`,
shootingGuide: [
'세로 9:16 촬영',
'점프컷 위주 편집 (CapCut)',
'블랙+골드 워드마크 좌측 상단 고정',
'트렌딩 사운드 + 자막 필수',
],
duration: '20초',
},
},
],
},
};

View File

@ -0,0 +1,440 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
*
*
* (`mockReport_grand.ts` 2026-04-14 ):
* - YouTube @grandsurgery_QnA: 2.37K , 332 , ~0/week ( )
* - Instagram @grand_korea: 4,013
* - Facebook @grandps.korea: 26,000 (KR+EN )
* - @grandprs:
* - : 9.8/1,533
* - , · , , 2005 (21)
* - Primary: Navy Blue (#1B3A6B), Accent: Sky Blue (#4A90D9)
*/
export const mockPlanGrand: MarketingPlan = {
id: 'grand',
reportId: 'grand',
clinicName: '그랜드성형외과',
clinicNameEn: 'Grand Plastic Surgery',
createdAt: '2026-04-14',
targetUrl: 'https://www.grandps.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'Grand Navy', hex: '#1B3A6B', usage: '공식 로고 메인, 헤딩, 강조 텍스트' },
{ name: 'Grand Sky Blue', hex: '#4A90D9', usage: '로고 악센트, CTA 포인트, 링크' },
{ name: 'Crisp White', hex: '#FAFAFA', usage: '배경, 카드, 여백' },
{ name: 'Deep Navy Text', hex: '#1B2A4A', usage: '본문 텍스트' },
{ name: 'Slate Gray', hex: '#5A6A7A', usage: '서브 텍스트, 메타 정보' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀', sampleText: '압구정 안면거상·리프팅 전문' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트', sampleText: '그랜드성형외과 이세환 원장' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩', sampleText: 'GRAND Plastic Surgery' },
],
logoRules: [
{ rule: 'Navy+Sky Blue 공식 로고 통일', description: '그랜드성형외과 공식 로고(네이비+스카이블루)를 모든 채널에서 동일하게 사용', correct: true },
{ rule: '원형 프로필 버전 제작', description: '소셜 프로필 전용 원형 버전 1080×1080 제작 필요 (현재 미제작)', correct: false },
{ rule: '가로형 로고: 영문+한글 병기', description: 'GRAND + "그랜드성형외과" 병기 가로형 버전 — 배너·헤더용', correct: true },
{ rule: '전문성 태그라인 추가', description: '"압구정 안면거상·리프팅 전문" 태그라인을 로고 하단 병기 검토', correct: true },
{ rule: '로고 주변 여백 확보', description: '로고 크기 30% 이상 여백 유지', correct: true },
],
toneOfVoice: {
personality: ['전문적', '신뢰감 있는', '기술 중심', '결과 지향', '차분한 권위'],
communicationStyle: '이세환 원장의 안면거상·리프팅 전문 기술력을 핵심으로, 과장 없이 정확한 의료 정보와 케이스 결과로 신뢰를 구축합니다.',
doExamples: [
'"안면거상은 피부가 아니라 근막을 바로잡는 수술입니다"',
'"21년, 결과로 말합니다 — 그랜드성형외과"',
'"이세환 원장의 Q&A — 궁금한 것을 직접 답합니다"',
'"강남언니 9.8점 · 1,533건의 검증"',
],
dontExamples: [
'"파격 할인! 오늘만!"',
'"연예인 시술 병원"',
'"100% 만족 보장"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: 'Grand Navy 원형 로고 1080×1080', bannerSpec: '2560×1440px, Navy 배경 + Sky Blue 악센트, "압구정 안면거상·리프팅 전문의 Q&A"', bioTemplate: '그랜드성형외과 Q&A — 이세환 원장\n안면거상·리프팅·코성형 전문\n02-547-5100 | grandps.com', currentStatus: 'incorrect' },
{ channel: 'Instagram', icon: 'instagram', profilePhoto: 'Grand Navy 원형 로고', bannerSpec: 'N/A (하이라이트: Navy 톤 아이콘 — 안면거상/리프팅/코/가슴/후기)', bioTemplate: '그랜드성형외과 공식 — 이세환 원장\n압구정역 | 02-547-5100\ngrandps.com', currentStatus: 'incorrect' },
{ channel: 'Facebook', icon: 'facebook', profilePhoto: 'Grand Navy 원형 로고', bannerSpec: '820×312px, Navy+Sky Blue 배너 + 전문성 태그라인', bioTemplate: '그랜드성형외과 공식 — 압구정 안면거상·리프팅 전문', currentStatus: 'incorrect' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: 'Grand 로고', bannerSpec: '블로그 상단: 로고 + "안면거상·리프팅 전문 Q&A" 카테고리 메뉴', bioTemplate: '그랜드성형외과 공식 블로그\n안면거상·리프팅·코성형·가슴성형 전문 정보', currentStatus: 'incorrect' },
{ channel: 'TikTok', icon: 'video', profilePhoto: 'Grand Navy 원형 로고', bannerSpec: 'N/A', bioTemplate: '그랜드성형외과 — 이세환 원장 Q&A\n압구정 | 02-547-5100', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '전문성 포지셔닝',
values: [
{ channel: 'YouTube', value: '그랜드성형외과Q&A (Q&A 채널명)', isCorrect: true },
{ channel: 'Instagram', value: '그랜드성형외과 공식 계정 (전문성 USP 없음)', isCorrect: false },
{ channel: 'Facebook', value: '그랜드성형외과 공식 Facebook', isCorrect: false },
{ channel: 'Website', value: '안면거상·리프팅 명시 (부분)', isCorrect: true },
],
impact: '"압구정 안면거상·리프팅 전문"이라는 핵심 USP가 YouTube 채널명에만 일부 반영. 나머지 채널에서 전문성 포지셔닝 부재',
recommendation: '전 채널 바이오에 "안면거상·리프팅 전문 이세환 원장" 명시',
},
{
field: '업로드 빈도',
values: [
{ channel: 'YouTube', value: '~0/week (사실상 중단)', isCorrect: false },
{ channel: 'Instagram', value: '저빈도', isCorrect: false },
{ channel: 'Naver Blog', value: '저빈도', isCorrect: false },
],
impact: '전 채널 업로드 중단 상태 — 신규 환자 유입 경로가 거의 없음',
recommendation: 'YouTube·Instagram·Blog 동시 재가동 — 안면거상 전문 콘텐츠 우선',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '@grandsurgery_QnA — 2.37K 구독자, 332 영상, 업로드 사실상 중단',
targetGoal: '10K 구독자 / 12개월, 주 2회 업로드 재개',
contentTypes: ['Shorts (332 영상 재편집)', '안면거상 전문 롱폼', '이세환 원장 Q&A 시리즈'],
postingFrequency: '주 2회 (Shorts 1 + 롱폼 격주)',
tone: '전문의 직접 설명 — "이세환 원장이 말하는 안면거상"',
formatGuidelines: [
'Shorts: 332 Q&A 영상에서 핵심 30-60초 추출',
'롱폼: 안면거상 케이스 스터디 월 2편 — 전후 비교 중심',
'썸네일: Navy+Sky Blue 통일, 이세환 원장 + 케이스 사진',
'타이틀: "압구정 안면거상 전문의가 말하는 ___" 패턴',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram_kr', channelName: 'Instagram', icon: 'instagram',
currentStatus: '@grand_korea — 4,013 팔로워, 카드뉴스·이미지 위주 (Reels 부족)',
targetGoal: '25K 팔로워 / 12개월, Reels 주 4개',
contentTypes: ['Reels (YouTube Shorts 동시)', 'Before/After Carousel', '이세환 원장 Q&A Stories'],
postingFrequency: '일 1회 + Stories 일 2개',
tone: '차분하고 전문적인 — 안면거상 결과로 설득',
formatGuidelines: [
'Reels: YouTube Shorts 동시 배포',
'Carousel: 안면거상·리프팅 전후 케이스 5-7장',
'Highlight: 안면거상/코성형/가슴/리프팅/후기 재구성',
'해시태그: #그랜드성형외과 #압구정안면거상 #이세환원장 #리프팅전문',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'facebook', channelName: 'Facebook', icon: 'facebook',
currentStatus: '@grandps.korea — 26,000 팔로워, 콘텐츠 빈도 낮음',
targetGoal: 'Instagram 크로스포스팅 + Pixel 리타겟 활용',
contentTypes: ['Instagram 크로스포스팅', '리타겟 광고 소재'],
postingFrequency: '주 2-3회 (자동 크로스포스팅 위주)',
tone: '안면거상·리프팅 결과 중심 — 40~55세 여성 타겟',
formatGuidelines: [
'Instagram 콘텐츠 자동 연동 유지',
'Pixel 광고: 안면거상 관심사 타겟 — 40~55세 여성',
'26K 팔로워 리타겟 활용',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '@grandprs — 업로드 저조, SEO 자산 방치',
targetGoal: '주 2회 포스팅 재가동, 12개월 내 월 15,000 방문자',
contentTypes: ['안면거상 SEO 가이드', '이세환 원장 Q&A 텍스트 변환', 'YouTube 영상 임베드+설명'],
postingFrequency: '주 2회',
tone: '"압구정 안면거상", "강남 리프팅" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 최적화 포스트',
'YouTube Q&A 영상 + 텍스트 답변 구조',
'핵심 키워드: "압구정 안면거상", "강남 리프팅", "그랜드성형외과 후기"',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.8점/10, 1,533 리뷰 — 응답 전략 부재',
targetGoal: '리뷰 응답률 80%, 2,500 리뷰 / 12개월',
contentTypes: ['리뷰 응답', '시술 정보 최신화'],
postingFrequency: '리뷰 응답 일 단위',
tone: '진심 어린 이세환 원장 답변',
formatGuidelines: [
'응답률 50%(3개월) → 80%(12개월)',
'1,533 리뷰 중 30개 선별 → SNS Carousel',
'부정 리뷰 24시간 내 응답',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
{
channelId: 'tiktok', channelName: 'TikTok (신규)', icon: 'video',
currentStatus: '계정 없음',
targetGoal: '5K 팔로워 / 12개월 — 30~50대 안면거상 고민층',
contentTypes: ['YouTube Shorts 크로스포스팅', '이세환 원장 숏클립 Q&A'],
postingFrequency: '주 3-5회 (Shorts 동시 배포)',
tone: '안면거상 궁금증 해소 — 가볍고 정확한 정보',
formatGuidelines: [
'332 YouTube 영상 Shorts 추출 → TikTok 동시',
'트렌딩 사운드 + 자막 필수',
'의료광고법 준수',
],
priority: 'P1',
customerJourneyStage: 'awareness',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '안면거상 전문 기술',
description: '이세환 원장의 딥플레인 안면거상 전문성을 케이스와 기술 설명으로 증명',
relatedUSP: 'Facelift Surgical Authority',
exampleTopics: ['딥플레인 안면거상 vs 일반 리프팅 차이', '안면거상 수술 전후 케이스 스터디', '리프팅 지속 시간의 과학'],
color: '#1B3A6B',
},
{
title: '이세환 원장 Q&A',
description: '332개 YouTube Q&A 영상 자산을 확장한 원장 직접 답변 시리즈',
relatedUSP: 'Direct Doctor Communication',
exampleTopics: ['안면거상 Q&A: 가장 많이 묻는 10가지', '코성형 후 리프팅 타이밍', '나이별 리프팅 권장 시술'],
color: '#4A90D9',
},
{
title: '강남언니 9.8점의 증거',
description: '1,533 리뷰에서 추출한 환자 목소리와 결과 중심 콘텐츠',
relatedUSP: 'Patient-Verified Excellence',
exampleTopics: ['안면거상 후기 — 6개월 경과', '40대 환자의 리프팅 결정 스토리', '재수술 없이 자연스러운 결과'],
color: '#5A6A7A',
},
{
title: '안전한 수술 환경',
description: '압구정 본원 시설·장비·안전 시스템 소개',
relatedUSP: 'Safety & Environment',
exampleTopics: ['그랜드성형외과 시설 투어', '수술 후 케어 프로토콜', '1:1 원장 책임 시스템'],
color: '#FAFAFA',
},
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '격주 1편', purpose: '안면거상 전문성 증명, 깊은 신뢰' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 4개', purpose: '332 영상 자산 재활용, 30~55대 도달' },
{ format: 'Carousel (Q&A)', channels: ['Instagram'], frequency: '주 2회', purpose: '안면거상 궁금증 해소, 저장 유도' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 2회', purpose: '"압구정 안면거상" 검색 상위 확보' },
{ format: 'Stories', channels: ['Instagram'], frequency: '일 2개', purpose: '이세환 원장 일상·상담 친밀감' },
{ format: 'Ad Creative', channels: ['Facebook', 'Instagram'], frequency: '월 4개', purpose: '40~55세 여성 리타겟' },
],
workflow: [
{ step: 1, name: 'Q&A 아카이브 정리', description: '332개 YouTube 영상 카테고리화 — 안면거상/리프팅/코/가슴 분류', owner: '마케팅 매니저', duration: '1주 (1회성)' },
{ step: 2, name: '주제 선정', description: '이세환 원장 Q&A 패턴 + 검색 키워드 매칭', owner: '마케팅 매니저', duration: '1일' },
{ step: 3, name: 'Shorts 추출 / 스크립트 작성', description: '기존 영상 핵심 구간 추출 또는 AI 초안', owner: 'AI + 편집', duration: '1-2일' },
{ step: 4, name: '원장 검수', description: '이세환 원장 의료 정확성 + 광고법 체크', owner: '이세환 원장', duration: '1일' },
{ step: 5, name: '비주얼 마감', description: 'Navy+Sky Blue 썸네일, 한글 자막', owner: '디자인', duration: '1일' },
{ step: 6, name: '배포', description: '채널별 최적 시간 + UTM 추적', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '이세환 원장 안면거상 롱폼 Q&A (10분)',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 Q&A 풀 영상 (한글 자막)' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '핵심 Q&A 답변 30-60초, 3채널 동시' },
{ format: 'Carousel 2개', channel: 'Instagram', description: '안면거상 Q&A 카드뉴스화' },
{ format: 'Blog Post', channel: 'Naver Blog', description: 'Q&A 스크립트 → 2,000자 SEO 포스트 (영상 임베드)' },
{ format: 'Stories 3개', channel: 'Instagram', description: '촬영 비하인드 스니펫' },
{ format: 'Ad Creative', channel: 'Facebook', description: '안면거상 Before/After + CTA 광고' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: YouTube 재가동 & 브랜드 정비',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '재가동 1편: 이세환 원장의 안면거상 철학' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: '프로필 리뉴얼 + 첫 Reel (안면거상 전후 케이스)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 안면거상 Q&A — 피부가 아닌 근막을 바로잡는 이유' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '안면거상이란? — 이세환 원장이 설명하는 기초 가이드' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 안면거상 VS 실리프팅 차이' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 강남언니 9.8점 리뷰 하이라이트' },
{ dayOfWeek: 5, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '그랜드성형외과 안면거상 후기 — 강남언니 9.8점의 의미' },
],
},
{
weekNumber: 2, label: 'Week 2: 안면거상 전문성 집중',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 딥플레인 안면거상 vs 일반 리프팅 — 이세환 원장 비교' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 안면거상 전후 케이스 #1' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 안면거상 회복 기간 Q&A' },
{ dayOfWeek: 2, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '딥플레인 안면거상 — 압구정 전문의가 설명하는 차이점' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 리프팅 시술 나이별 추천 가이드' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 이세환 원장 한 줄 Q&A' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 40~55세 여성 — 안면거상 상담 CTA' },
],
},
{
weekNumber: 3, label: 'Week 3: 코성형·가슴성형 콘텐츠',
entries: [
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '코성형 Q&A 10가지 — 그랜드 이세환 원장 답변' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 코성형 전후 — 자연스러운 라인' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 재수술 피하는 방법' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 가슴성형 보형물 종류 비교' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '그랜드성형외과 코성형 후기 — 강남언니 검증' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 가슴성형 회복 현실 Q&A' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 최적화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 그랜드성형외과 — 21년의 기술력' },
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '첫 성형 상담 준비 가이드 — 그랜드성형외과 압구정' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 압구정 그랜드 시설 투어' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 이세환 원장 이달의 케이스' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 상담 예약 안내 — 압구정역 5분' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 월말 안면거상 상담 CTA' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '그랜드성형외과 9.8점 — 1,533 리뷰의 진심' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 12, color: '#1B3A6B' },
{ type: 'blog', label: '블로그', count: 8, color: '#4A90D9' },
{ type: 'social', label: 'Instagram', count: 8, color: '#5A6A7A' },
{ type: 'ad', label: '광고', count: 2, color: '#FAFAFA' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '332개 Q&A 영상 아카이브', description: '2017년 이래 안면거상·코성형·가슴 Q&A 332개 — Shorts 추출 최우선', repurposingSuggestions: ['Shorts 100개 추출 (P0)', 'Instagram Reels', 'TikTok 동시', '블로그 임베드'], status: 'collected' },
{ id: 'a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 리뷰 1,533건', description: '9.8점/10 — 안면거상·리프팅 케이스 중심', repurposingSuggestions: ['베스트 30개 → Carousel', 'Instagram Stories', '광고 소셜프루프', 'Blog 후기'], status: 'pending' },
{ id: 'a3', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '압구정 본원 시설 사진', description: '본원 내외부 고화질', repurposingSuggestions: ['Instagram 시설 Reel', 'YouTube B-roll', '블로그 위치'], status: 'collected' },
{ id: 'a4', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '이세환 원장 프로필', description: '이세환 원장 고화질 프로필 + 경력', repurposingSuggestions: ['YouTube 인터뷰 섬네일', 'Carousel 원장 소개', '광고 신뢰도 소재'], status: 'collected' },
{ id: 'a5', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 설명 텍스트', description: '안면거상·리프팅·코·가슴 시술별 상세', repurposingSuggestions: ['블로그 SEO 소스', 'Carousel', '광고 카피'], status: 'collected' },
{ id: 'a6', source: 'social', sourceLabel: 'Instagram', type: 'photo', title: '@grand_korea 게시물 800개', description: '시술 설명·카드뉴스 누적 800개', repurposingSuggestions: ['고성과 게시물 Reel 변환', 'Carousel 재편집'], status: 'collected' },
{ id: 'a7', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '안면거상 전후 사진 갤러리', description: '이세환 원장 케이스 — 동의 환자 대상', repurposingSuggestions: ['Instagram B/A 시리즈', 'Shorts 소스', '상담 자료'], status: 'collected' },
{ id: 'a8', source: 'homepage', sourceLabel: '홈페이지', type: 'video', title: '이세환 원장 소개 영상 (제작 필요)', description: '21년 경력·전문성 브랜드 영상 신규 제작', repurposingSuggestions: ['YouTube 채널 트레일러', 'Instagram 브랜드 Reel', '웹사이트 히어로'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '안면거상 수술 Q&A 베스트', views: 0, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Instagram Carousel', '블로그 임베드'] },
{ title: '리프팅 지속 시간 Q&A', views: 0, type: 'Long', repurposeAs: ['Shorts 3개', 'TikTok 동시', 'Blog'] },
{ title: '코성형 재수술 케이스', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Reel', 'Carousel'] },
{ title: '가슴성형 보형물 Q&A', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Reel', 'Blog'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '332 Q&A 영상 → 안면거상 Shorts 추출', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'high',
priority: 'high',
outputs: [
{ format: 'Shorts 50개 (1차)', channel: 'YouTube / Instagram / TikTok', description: '안면거상·리프팅 카테고리 우선 — 3채널 동시' },
{ format: 'Carousel 10개', channel: 'Instagram', description: 'Q&A 카드뉴스화' },
{ format: 'Blog Post 5개', channel: 'Naver Blog', description: 'Q&A 스크립트 → SEO 포스트 변환' },
],
},
{
sourceVideo: { title: '강남언니 1,533 리뷰 콘텐츠화', views: 1533, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Carousel 15개', channel: 'Instagram', description: '안면거상·리프팅 후기 카드뉴스 — 익명화' },
{ format: '광고 소재', channel: 'Facebook / Instagram', description: '"9.8점 강남언니 검증" 소셜프루프 광고' },
{ format: 'Blog 후기 포스트', channel: 'Naver Blog', description: '케이스별 상세 후기 블로그 변환' },
],
},
{
sourceVideo: { title: '이세환 원장 인터뷰 (신규 제작)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개', channel: 'YouTube / TikTok', description: '"21년 안면거상 전문의가 말하는" 핵심 구간' },
{ format: 'Carousel', channel: 'Instagram', description: '이세환 원장 철학 카드뉴스' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '인터뷰 → SEO 포스트' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'wf-001',
title: '딥플레인 안면거상 vs 리프팅 Shorts',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"피부만 당기는 리프팅, 효과는 1년도 안 갑니다."\n\n[본문 — 3~25초]\n"그랜드성형외과는 근막층(SMAS)부터 바로잡습니다."\n"딥플레인 안면거상 — 자연스럽고 오래가는 이유입니다."\n"이세환 원장이 21년간 선택한 방식."\n\n[CTA — 25~30초]\n"강남언니 9.8점 · 1,533 리뷰 — 프로필 링크"`,
shootingGuide: [
'332 영상 중 안면거상 카테고리 핵심 구간 추출',
'Navy+Sky Blue 워터마크 하단 고정',
'한글 자막 필수',
'환자 동의서 확인 + 비식별화',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '안면거상 Q&A 5가지 Carousel',
contentType: 'image-text',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
imageTextDraft: {
type: 'cardnews',
headline: '안면거상, 가장 많이 묻는 5가지 — 이세환 원장 직접 답변',
copy: [
'[카드 1] Q. 몇 살부터? → A. 처짐의 정도가 기준. 나이보다 상태를 봅니다.',
'[카드 2] Q. 실리프팅이랑 다른가요? → A. 실리프팅은 임시, 안면거상은 근막층 교정.',
'[카드 3] Q. 회복 기간은? → A. 일상 복귀 1~2주, 완전 회복 3~6개월.',
'[카드 4] Q. 흉터? → A. 귀 앞뒤 은닉 절개 — 자연스럽게.',
'[카드 5] 강남언니 9.8점 · 그랜드성형외과 이세환 원장',
],
layoutHint: '5장 세로형, Navy+Sky Blue, 마지막 카드에 상담 예약 CTA',
},
},
{
id: 'wf-003',
title: '안면거상이란? SEO 블로그 재가동 1편',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '안면거상이란? — 압구정 전문의 이세환 원장이 직접 설명합니다',
copy: [
'안면거상은 피부를 당기는 시술이 아닙니다. 근막층(SMAS)의 처짐을 바로잡는 수술입니다.',
'그랜드성형외과 이세환 원장은 21년간 안면거상·리프팅에 집중해왔습니다.',
'강남언니 9.8점 / 1,533 리뷰 — 결과로 검증된 전문성.',
'앞으로 안면거상·리프팅·코성형 전문 정보를 매주 2회 업로드합니다.',
],
layoutHint: '1200px 썸네일 + 2,000자, 키워드: 안면거상, 압구정 안면거상, 그랜드성형외과, 이세환 원장',
},
},
{
id: 'wf-004',
title: '이세환 원장 인터뷰 — 21년의 기술력',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'approved',
scheduledDate: '2026-04-21',
videoDraft: {
script: `[오프닝]\n"안녕하세요, 그랜드성형외과 이세환입니다."\n"21년간 안면거상에만 집중한 이유를 말씀드립니다."\n\n[핵심 1: 왜 안면거상인가]\n"처짐은 피부 문제가 아닙니다. 근막이 늘어나는 겁니다."\n\n[핵심 2: 딥플레인의 차이]\n"표면 리프팅은 1년, 딥플레인은 5~10년 — 이유가 있습니다."\n\n[클로징]\n"강남언니 1,533분이 남긴 9.8점이 증거입니다. 그랜드성형외과."`,
shootingGuide: [
'압구정 원장실 자연광',
'이세환 원장 정면 + 3/4 앵글',
'안면거상 케이스 그래픽 인서트',
'Navy+Sky Blue 인트로/아웃트로',
],
duration: '8분',
},
},
],
},
};

View File

@ -0,0 +1,508 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
* ( / Seoul i Plastic Surgery)
*
* (mockReport_irum.ts, 2026-04-14 ):
* - YouTube @SEOULiPS.: 322, 155 ( )
* - Instagram: @seoulips KR 826 / @seouli_ps_th 5K / @seouli_jp
* - Facebook: /
* - : 9.4 / 86 (5 )
* - : ·· , ·
* - : 2015 (11),
*
* mockPlan(View) .
*/
export const mockPlanIrum: MarketingPlan = {
id: 'irum',
reportId: 'irum',
clinicName: '이룸성형외과 (서울아이)',
clinicNameEn: 'Seoul i Plastic Surgery',
createdAt: '2026-04-14',
targetUrl: 'https://www.seoulips.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'Forest Green', hex: '#0D4F3C', usage: '주요 배경·헤더·CTA 버튼 — 신뢰·자연·안전 상징' },
{ name: 'Mint', hex: '#2ECC71', usage: '강조색 — 성장·생명력·외국인 환자 친화성 표현' },
{ name: 'Off-White', hex: '#F4FAF7', usage: '섹션 배경 — 그린 계열과 대비되는 밝고 깨끗한 여백' },
{ name: 'Deep Forest', hex: '#07331F', usage: '헤딩 텍스트·고대비 강조 영역' },
{ name: 'Stone Gray', hex: '#6B7280', usage: '서브 텍스트·부연 설명 — 중립적 가독성' },
],
fonts: [
{ family: 'Pretendard', weight: '400 / 600 / 700', usage: 'KR 본문·UI 레이블', sampleText: '서울아이성형외과 — 안전하고 바른 성형' },
{ family: 'Playfair Display', weight: '400 / 700', usage: '영문 헤딩·Seoul i 영문 브랜드 표기', sampleText: 'Seoul i Plastic Surgery' },
{ family: 'Noto Sans', weight: '400 / 600', usage: '태국어·일본어 다국어 콘텐츠 — 현지 가독성 확보', sampleText: 'Seoul i — ศัลยกรรม · 整形外科' },
],
logoRules: [
{ rule: '영문 로고 글로벌 우선', description: '"Seoul i Plastic Surgery" 영문 로고를 TH/JP 계정·Facebook·LINE에 일관 사용 — 서울아이/이룸 혼용 금지', correct: true },
{ rule: '국내 채널: 서울아이 KR + 영문 병기', description: '국내 KR 채널(@seoulips, Naver Blog)에는 "서울아이 Seoul i" KR+EN 병기 버전 사용', correct: true },
{ rule: 'Forest Green 배경 통일', description: '로고 단색 배경은 반드시 Forest Green(#0D4F3C) 또는 Off-White(#F4FAF7) 계열만 허용', correct: true },
{ rule: '다국어 로고 4개 버전', description: 'KR / EN / TH / JP 4개 언어 버전 제작 → 각 Instagram 계정 프로필 사진에 언어별 적용 필요', correct: false },
{ rule: '프로필 사진 1080×1080 통일', description: '채널별 해상도 편차 존재 — 1080×1080 단일 원형 로고로 전 채널 통일 필요', correct: false },
],
toneOfVoice: {
personality: ['안심되는', '전문적', '따뜻한', '글로벌', '섬세한'],
communicationStyle: '외국인 환자가 이해하기 쉬운 쉬운 단어를 선택하되, 코성형·눈밑지방·리프팅 전문성을 교육적 어조로 전달합니다. "안전하고 바른 성형"이라는 서울아이 슬로건을 다국어로 일관 표현.',
doExamples: [
'"안전하고 바른 성형을 위해 서울아이는 항상 노력합니다"',
'"강남언니 9.4점, 86명의 진솔한 후기"',
'"코성형·눈밑지방·리프팅 11년 전문성"',
'"태국·일본 환자도 한국어 걱정 없이 상담하세요"',
],
dontExamples: [
'"파격 할인! 오늘까지만!"',
'"연예인이 선택한 병원"',
'번역 품질이 낮은 직역체 TH/JP 콘텐츠',
'"서울아이 / 이룸 / Seoul i" 브랜드명 혼용',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: '서울아이 Forest Green 배경 원형 로고 1080×1080', bannerSpec: '2560×1440px, Forest Green 배경 + "서울아이 Seoul i Plastic Surgery" + 코성형·눈밑지방·리프팅 서브타이틀', bioTemplate: '안전하고 바른 성형 — 서울아이\n코성형·눈밑지방·실리프팅 전문\nseoulips.com | 강남역 인근', currentStatus: 'incorrect' },
{ channel: 'Instagram KR', icon: 'instagram', profilePhoto: '서울아이 KR 로고 원형', bannerSpec: 'Highlights: Forest Green 아이콘 세트 (코성형/눈밑지방/리프팅/후기/외국인환영)', bioTemplate: '서울아이성형외과 | 코성형·눈밑지방·리프팅\n강남역 | 02-555-0900 | seoulips.com', currentStatus: 'incorrect' },
{ channel: 'Instagram TH', icon: 'instagram', profilePhoto: '서울아이 TH 로고 원형', bannerSpec: 'Highlights: ศัลยกรรมจมูก / ใต้ตา / ลิฟติ้ง / รีวิว / ปรึกษา 5개 카테고리', bioTemplate: 'Seoul i Plastic Surgery | ศัลยกรรมความงามเกาหลี | LINE: @seoulips_th', currentStatus: 'incorrect' },
{ channel: 'Instagram JP', icon: 'instagram', profilePhoto: '서울아이 JP 로고 원형', bannerSpec: 'Highlights: 鼻整形 / 目の下 / リフティング / 口コミ / ご予約 5개', bioTemplate: 'Seoul i Plastic Surgery | 韓国美容整形 | LINE: @seoulips_jp', currentStatus: 'missing' },
{ channel: 'Facebook (신규)', icon: 'facebook', profilePhoto: '서울아이 EN 로고', bannerSpec: '820×312px, Forest Green + "Seoul i Plastic Surgery" + 코·눈밑지방·리프팅 서브타이틀', bioTemplate: 'Seoul i Plastic Surgery — 코성형 · 눈밑지방 · 리프팅 전문 | 태국·일본 환자 환영', currentStatus: 'missing' },
{ channel: 'LINE (신규)', icon: 'globe', profilePhoto: '서울아이 EN 로고', bannerSpec: 'LINE 채널 아트: Forest Green + "Seoul i — 일본 환자 전용 상담"', bioTemplate: 'Seoul i Plastic Surgery 공식 LINE — 일본어 상담 가능 | seoulips.com', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '브랜드명 혼용',
values: [
{ channel: 'YouTube', value: '서울아이 Seoul i Plastic Surgery', isCorrect: true },
{ channel: 'Instagram KR', value: '서울아이성형외과', isCorrect: true },
{ channel: '강남언니', value: '이룸(서울아이)성형외과', isCorrect: false },
{ channel: 'Website', value: 'seoulips.com (서울아이 + 이룸 혼용)', isCorrect: false },
],
impact: '"이룸" / "서울아이" / "Seoul i" 3개 명칭 혼용 — 신규 환자의 브랜드 인지 혼란 유발. 특히 외국인 환자는 동일 병원임을 인지 못 할 가능성.',
recommendation: '국내 KR: "서울아이" 통일 / 글로벌: "Seoul i Plastic Surgery" 통일 — 강남언니 프로필명도 "서울아이성형외과"로 수정',
},
{
field: 'YouTube 성장 정체',
values: [
{ channel: 'YouTube @SEOULiPS.', value: '155개 영상 · 구독자 322명 (영상당 구독자 2명 미만)', isCorrect: false },
{ channel: 'Instagram TH @seouli_ps_th', value: '200개 게시물 · 5,000 팔로워 (10배 차이)', isCorrect: true },
{ channel: 'Instagram KR @seoulips', value: '600개 게시물 · 826 팔로워', isCorrect: false },
],
impact: 'YouTube 글로벌 채널 잠재력이 완전히 미활용 중 — 다국어 자막 추가만으로 즉시 글로벌 검색 노출 10배 이상 확대 가능',
recommendation: '기존 155개 영상에 EN/TH 자막 우선 30개 추가 P0 — 신규 영상 제작보다 기존 자산 글로벌 전환이 ROI 더 높음',
},
{
field: '3개국 Instagram 크로스채널 단절',
values: [
{ channel: '@seoulips (KR)', value: '826 팔로워 — KR 단독 운영', isCorrect: false },
{ channel: '@seouli_ps_th (TH)', value: '5,000 팔로워 — TH 단독 운영', isCorrect: false },
{ channel: '@seouli_jp (JP)', value: '팔로워 미확인 — JP 단독 운영', isCorrect: false },
],
impact: 'KR/TH/JP 3개 계정이 서로 태그·크로스 배포 없음 — 계정 간 팔로워 유입 기회 0% 상태',
recommendation: 'KR 원본 → TH/JP 번역 크로스 배포 파이프라인 구축 (자동화) — 제작 비용 동일, 노출 3배',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '@SEOULiPS. — 구독자 322명 · 155개 영상 · 업로드 월 1~2회 · 성장 완전 정체',
targetGoal: '구독자 3,000명 / 12개월, 주 2회 정기 업로드 + 다국어 자막 100% 적용',
contentTypes: ['다국어 자막 추가 (기존 155개 → EN/TH/JP)', 'Shorts 50개 추출 (기존 영상 재활용)', '전문의 Q&A 롱폼 (코성형·눈밑지방)', '외국인 환자 브이로그'],
postingFrequency: '주 2회 (Shorts 1 + 롱폼 격주), 자막 추가는 월 10개 이상',
tone: '교육적·안심감 — 서울아이 전문의 직접 설명, 외국인 친화적 쉬운 언어',
formatGuidelines: [
'기존 155개 영상에 EN/TH 자막 우선 30개 추가 — 즉시 글로벌 노출 확대 (P0)',
'Shorts: 기존 영상에서 1분 클립 추출 → KR/EN/TH/JP 다국어 Shorts',
'플레이리스트 재정비: 코성형 / 눈밑지방 / 리프팅 / 외국인 환자 브이로그 4개',
'썸네일: Forest Green 배경 + "서울아이 Seoul i" 워터마크 통일',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram_kr', channelName: 'Instagram KR', icon: 'instagram',
currentStatus: '@seoulips — 826 팔로워 · 게시물 600개 · Reels 30개 (부족)',
targetGoal: '팔로워 10,000명 / 12개월, Reels 주 5개',
contentTypes: ['Reels (코성형·눈밑지방 Before/After)', 'Carousel (강남언니 86 리뷰 스토리화)', 'Stories (병원 일상)', '시술 교육 카드뉴스'],
postingFrequency: '일 1회 + Stories 일 2~3개',
tone: '따뜻하고 안심되는 — 전문성과 친근함의 균형',
formatGuidelines: [
'Reels 주 5회 업로드 (KR 자막 기본 + EN 자막 추가로 해외 노출 병행)',
'@seouli_ps_th / @seouli_jp 계정과 상호 태그 → 팔로워 크로스 유입',
'강남언니 86 리뷰 핵심 발췌 → Carousel + Story 월 4회',
'Highlights 재구성: 코성형 / 눈밑지방 / 리프팅 / 후기 / 외국인 환영',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'instagram_th', channelName: 'Instagram TH', icon: 'instagram',
currentStatus: '@seouli_ps_th — 5,000 팔로워 · 200개 게시물 · KR 계정과 크로스 배포 없음',
targetGoal: '팔로워 15,000명 / 12개월, KR 콘텐츠 번역 자동 배포 구축',
contentTypes: ['KR 원본 번역 배포 (태국어)', 'Reels (태국어 자막)', '태국 환자 Before/After', 'LINE 상담 CTA'],
postingFrequency: '주 3회 (KR 번역 배포 2 + 태국 전용 1)',
tone: 'Warm & informative — 태국 환자 관점에서 한국 성형 정보 제공',
formatGuidelines: [
'@seoulips KR 게시물 → 태국어 번역 → 동시 배포 파이프라인 구축 (P0)',
'Highlights: ศัลยกรรมจมูก / ใต้ตา / ลิฟติ้ง / รีวิว / ปรึกษาฟรี 5개',
'바이오 + 게시물에 LINE 상담 링크 추가 — 태국 환자 직접 전환 경로',
'Reels 주 3회: 태국어 자막 코성형 Before/After + 태국어 상담 안내',
],
priority: 'P0',
customerJourneyStage: 'conversion',
},
{
channelId: 'instagram_jp', channelName: 'Instagram JP', icon: 'instagram',
currentStatus: '@seouli_jp — 팔로워 미확인 · 일본어 운영 중 · 업로드 비정기',
targetGoal: '팔로워 3,000명 / 12개월, LINE 연동으로 직접 상담 전환',
contentTypes: ['일본어 네이티브 콘텐츠', 'KR 번역 배포 (일본어)', 'LINE 상담 CTA'],
postingFrequency: '주 2회 (KR 번역 배포 1 + 일본 전용 1)',
tone: '자연스러운 일본어 — 직역체 금지, 네이티브 검수 필수',
formatGuidelines: [
'LINE QR코드를 바이오와 모든 게시물에 삽입 — 일본 환자 전환 핵심 경로',
'네이티브 일본어 검수 후 게시 (직역체 금지)',
'Highlights: 鼻整形 / 目の下の脂肪 / リフティング / 口コミ / ご予約 5개',
'KR 원본 → 일본어 번역 배포로 업로드 부담 감소',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
{
channelId: 'facebook_new', channelName: 'Facebook (신규 개설)', icon: 'facebook',
currentStatus: '기존 페이지 비공개/삭제 확인 — 외국인 환자 광고 채널 완전 공백',
targetGoal: '태국·일본 환자 타겟 광고 집행 인프라 구축 (3개월 내)',
contentTypes: ['영문/태국어 광고 소재', 'Instagram TH 크로스포스팅', 'Before/After 광고'],
postingFrequency: '주 2회 + 광고 캠페인 상시',
tone: 'Professional & welcoming — Korea medical tourism storytelling',
formatGuidelines: [
'"Seoul i Plastic Surgery" 영문명으로 신규 개설 (P0)',
'seoulips.com 픽셀 설치 → Instagram TH/JP 방문자 리타겟팅 광고',
'코성형·눈밑지방 시술별 태국어·일본어 광고 소재 제작 후 집행',
'WhatsApp 연동 검토 — 동남아 시장 직접 상담',
],
priority: 'P0',
customerJourneyStage: 'awareness',
},
{
channelId: 'line_new', channelName: 'LINE (신규 개설)', icon: 'globe',
currentStatus: '미개설 — 일본·태국 환자 직접 상담 경로 없음',
targetGoal: 'LINE 공식 계정 개설 + 일본어 FAQ 자동응답 + 친구 500명 (6개월)',
contentTypes: ['일본어 자동응답 FAQ', '상담 예약 플로우', 'TH/JP 전용 이벤트 메시지'],
postingFrequency: '일 단위 자동응답 + 월 2회 메시지 발송',
tone: '친절하고 안심되는 — 일본어·태국어 네이티브 수준',
formatGuidelines: [
'LINE Official Account 개설 — 일본어 자동응답 + 상담 예약 플로우 설정 (P0)',
'@seouli_jp 바이오 + 모든 게시물에 LINE QR코드 삽입',
'코성형·눈밑지방·리프팅 시술별 FAQ 자동 응답 (일본어)',
'친구 수 목표: 500명 (6개월) → 1,000명 (12개월)',
],
priority: 'P0',
customerJourneyStage: 'conversion',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: 'blog.naver.com/seoulips — 공식 블로그 운영 중, 업로드 저조',
targetGoal: '월 8회 SEO 포스팅 정착, 코성형·눈밑지방 검색 상위 노출',
contentTypes: ['코성형 SEO 가이드', '눈밑지방 FAQ', '강남언니 리뷰 가공', 'YouTube 임베드'],
postingFrequency: '주 2회 (1차 1개월 내 시작)',
tone: '정보성 전문가 — "코성형 가격" / "눈밑지방 제거" / "실리프팅" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 최적화 포스트, 이미지 10장 + YouTube 임베드 1개',
'키워드 맵: "코성형 가격", "눈밑지방 제거", "실리프팅", "강남 코성형"',
'강남언니 86 리뷰 핵심 발췌 → 블로그 후기 형식 재가공',
'시술 영상 임베드 → 체류 시간 증가 → 네이버 검색 상위 노출',
],
priority: 'P1',
customerJourneyStage: 'consideration',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.4점 / 86 리뷰 — 5개 병원 중 가장 적음 · SNS 홍보 미흡',
targetGoal: '리뷰 1,500건 (12개월), 월 신규 리뷰 20건 목표',
contentTypes: ['리뷰 응답 (일 단위)', '시술 정보 업데이트', '리뷰 SNS 콘텐츠화'],
postingFrequency: '리뷰 응답: 일 단위 / 프로필 업데이트: 월 1~2회',
tone: '진심 어린 의료진 답변 — 형식적 답변 지양',
formatGuidelines: [
'수술 후 3개월 추적 관리 시 강남언니 리뷰 작성 요청 → 월 20건 목표',
'86개 기존 리뷰 핵심 발췌 → Instagram 카드뉴스 + YouTube 추천사 영상',
'부정 리뷰 24시간 내 응답 원칙',
'강남언니 프로필에 다국어 시술 설명 추가 + 최신 Before/After 업로드',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '글로벌 코성형·눈밑지방 전문성',
description: '11년 경력의 코성형·눈밑지방·실리프팅 전문 기술을 KR/TH/JP 3개 언어로 전달 — "안전하고 바른 성형" 서울아이 원칙',
relatedUSP: '11 Years of Rhinoplasty & Blepharoplasty Expertise',
exampleTopics: ['코성형 3가지 방법 비교 (한/태/영/일 자막)', '눈밑지방 제거 Before/After 30일 변화', '실리프팅 효과와 지속 기간', '코성형 붓기 타임라인 Day 1~30'],
color: '#0D4F3C',
},
{
title: '3개국 다국어 외국인 환자 여정',
description: '태국·일본 실제 환자의 내원 전후 여정을 현지어로 다큐멘터리화 — 정보 제공 + 신뢰 구축',
relatedUSP: 'KR/TH/JP Multilingual Patient Experience',
exampleTopics: ['태국에서 서울아이로 — 코성형 여행 브이로그', '日本から来た!목의 下の脂肪取り手術体験記', '외국인 환자 상담 과정 공개', '강남역에서 서울아이까지 오는 방법'],
color: '#2ECC71',
},
{
title: '86 리뷰의 진심 — 강남언니 증거',
description: '강남언니 9.4점 / 86 실사용 후기를 콘텐츠 자산으로 전환 — 신뢰 증거 극대화',
relatedUSP: 'Patient-Validated 9.4 Stars',
exampleTopics: ['"코성형 후 진짜 자연스럽다" — 강남언니 리뷰 발췌', '86개 리뷰 공통 키워드: 자연스럽다 / 회복 빠르다 / 친절하다', '강남언니 9.4점 달성 감사 이벤트', '리뷰 100 달성 기념 특별 콘텐츠'],
color: '#07331F',
},
{
title: 'K-Beauty 글로벌 허브 — 서울 강남',
description: '강남역 입지와 K-Beauty 트렌드를 활용한 글로벌 환자 유입 전략 — 서울 방문 가이드 + 한국 성형 문화',
relatedUSP: 'K-Beauty Seoul Gangnam Destination',
exampleTopics: ['강남에서 수술 후 회복하기 — 숙소/식당/관광지 추천', '왜 태국/일본 분들이 한국에서 코성형을 하나요?', '서울아이 찾아오는 방법 — 강남역 3번 출구 5분', '2026 코성형 트렌드 — 서울아이 전문의 전망'],
color: '#6B7280',
},
],
typeMatrix: [
{ format: 'YouTube 전문의 Q&A (다국어 자막)', channels: ['YouTube'], frequency: '주 1회', purpose: '코성형·눈밑지방 전문성 증명, 깊은 신뢰 구축' },
{ format: 'Shorts / Reels (다국어)', channels: ['YouTube', 'Instagram KR', 'Instagram TH', 'Instagram JP'], frequency: '주 5개', purpose: '기존 155개 영상 글로벌 재활용, 도달 확대' },
{ format: 'Carousel (리뷰 스토리)', channels: ['Instagram KR'], frequency: '주 1회', purpose: '강남언니 86 리뷰 → 사회적 증거화' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 2회', purpose: '코성형·눈밑지방 검색 유입, SEO 강화' },
{ format: '외국인 환자 브이로그', channels: ['YouTube', 'Instagram TH', 'Instagram JP'], frequency: '월 2회', purpose: '태국·일본 환자 여정 콘텐츠 — 전환 유도' },
{ format: 'LINE 메시지', channels: ['LINE'], frequency: '월 2회', purpose: '일본 환자 직접 상담 전환 채널' },
],
workflow: [
{ step: 1, name: 'KR 원본 제작', description: '한국어 원본 콘텐츠 (영상/카드뉴스/Reels) 제작 — 후반작업 포함', owner: '콘텐츠팀', duration: '5~7일' },
{ step: 2, name: '다국어 번역 + 자막', description: 'EN/TH/JP 번역 및 자막 파일 제작 — 네이티브 검수 필수', owner: '번역팀', duration: '2~3일' },
{ step: 3, name: '플랫폼별 최적화', description: 'Instagram 계정별(KR/TH/JP) 포맷·해시태그 최적화 후 예약 등록', owner: 'SNS 담당', duration: '1일' },
{ step: 4, name: '크로스 배포 + 성과 추적', description: '전 채널 동시 배포 후 UTM 기반 유입 추적 + 강남언니 상담 연계 확인', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '1개 코성형·눈밑지방 전문의 설명 영상 (10분) 또는 기존 155개 영상 아카이브 1편',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 풀 영상 업로드 (KR 기본 + EN/TH/JP 자막 추가)' },
{ format: 'Shorts 3개', channel: 'YouTube / Instagram KR', description: '핵심 구간 30~60초 추출, KR+EN 자막 동시 배포' },
{ format: 'Instagram TH Reels', channel: 'Instagram TH (@seouli_ps_th)', description: 'Shorts 태국어 번역 버전 — @seouli_ps_th 배포' },
{ format: 'Instagram JP Reels', channel: 'Instagram JP (@seouli_jp)', description: 'Shorts 일본어 번역 버전 — @seouli_jp 배포' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '영상 스크립트 → 2,000자 SEO 포스트 (영상 임베드 포함)' },
{ format: 'LINE 메시지', channel: 'LINE', description: '영상 요약 + 상담 CTA — 일본 LINE 친구 대상 발송' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: 다국어 기반 구축 — 브랜드 통일 선언',
entries: [
{ dayOfWeek: 0, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: '서울아이 리브랜딩 — "Seoul i Plastic Surgery" 공식 발표 Reels' },
{ dayOfWeek: 1, channel: 'Instagram TH', channelIcon: 'instagram', contentType: 'social', title: 'Seoul i Plastic Surgery — เปิดตัวแบรนด์ใหม่ (TH Reels)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형이란? — 1분 설명 (KR/EN/TH/JP 자막)' },
{ dayOfWeek: 3, channel: 'Instagram JP', channelIcon: 'instagram', contentType: 'social', title: 'ソウルアイ — 鼻整形専門クリニック紹介 (JP Reels)' },
{ dayOfWeek: 4, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 86 리뷰 감사 — 핵심 후기 발췌 5선' },
{ dayOfWeek: 5, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '코성형 방법 3가지 비교 — 어떤 수술이 나에게 맞을까' },
],
},
{
weekNumber: 2, label: 'Week 2: 코성형 전문성 — 교육 콘텐츠 집중',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '코성형 상담부터 수술까지 — 서울아이 전문의 Q&A (EN/TH 자막)' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reels: 코성형 붓기 타임라인 — Day 1~30 비교' },
{ dayOfWeek: 2, channel: 'Instagram TH', channelIcon: 'instagram', contentType: 'social', title: 'ไทม์ไลน์ฟื้นตัวหลังทำจมูก — 1~30 วัน (TH 번역)' },
{ dayOfWeek: 3, channel: 'Instagram JP', channelIcon: 'instagram', contentType: 'social', title: '鼻整形の腫れはいつ引く? リカバリータイムライン (JP 번역)' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '눈밑지방 제거 vs 필러 — 차이와 선택 기준 (SEO)' },
{ dayOfWeek: 5, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Story: 강남언니 9.4점 리뷰 — "정말 자연스럽다" 후기 공유' },
],
},
{
weekNumber: 3, label: 'Week 3: 외국인 환자 여정 — 태국 집중',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '태국에서 서울아이로 — 코성형 여행 브이로그 (태국어 자막)' },
{ dayOfWeek: 1, channel: 'Instagram TH', channelIcon: 'instagram', contentType: 'social', title: 'จากกรุงเทพมาโซล — ทำจมูกที่ Seoul i (TH Reels)' },
{ dayOfWeek: 2, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reels: 눈밑지방 제거 전후 — 30대 여성 케이스' },
{ dayOfWeek: 3, channel: 'Instagram TH', channelIcon: 'instagram', contentType: 'social', title: 'ขั้นตอนนัดหมายที่ Seoul i — ง่ายกว่าที่คิด (TH 카드뉴스)' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(TH): Seoul i — 태국 환자 코성형 신규 광고 런칭' },
{ dayOfWeek: 5, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '실리프팅이란? 효과와 지속 기간 — 서울아이 케이스 분석' },
],
},
{
weekNumber: 4, label: 'Week 4: LINE 오픈 + 일본 환자 전환',
entries: [
{ dayOfWeek: 0, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 86→100 리뷰 달성 감사 이벤트' },
{ dayOfWeek: 1, channel: 'Instagram JP', channelIcon: 'instagram', contentType: 'social', title: '日本からソウルアイへ — 目の下の脂肪取りブイログ (JP)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '실리프팅이란? 서울아이 전문의 1:1 설명 (4개 언어 자막)' },
{ dayOfWeek: 3, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reels: 서울아이 상담실 공개 — 외국인 환자 첫 방문 가이드' },
{ dayOfWeek: 4, channel: 'Instagram TH', channelIcon: 'instagram', contentType: 'social', title: 'ก่อน-หลัง เสริมจมูก Seoul i — คนไข้จริงรีวิว (TH)' },
{ dayOfWeek: 5, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '강남언니 9.4점 달성 스토리 — 서울아이가 받은 86개의 진심' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 12, color: '#0D4F3C' },
{ type: 'blog', label: '블로그', count: 4, color: '#2ECC71' },
{ type: 'social', label: 'Instagram KR/TH/JP', count: 14, color: '#07331F' },
{ type: 'ad', label: '광고 (Facebook TH)', count: 1, color: '#6B7280' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'ir-a1', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: 'YouTube 영상 155개 아카이브 (@SEOULiPS.)', description: '2019년 개설 이래 누적 155개 영상 — 다국어 자막 추가 + Shorts 추출 최우선 자산', repurposingSuggestions: ['EN/TH/JP 자막 추가 30개 (P0)', 'Shorts 50개 추출', 'Instagram Reels 변환', 'Blog 포스트 임베드'], status: 'collected' },
{ id: 'ir-a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 환자 리뷰 86건 (9.4점)', description: '5개 병원 중 가장 적지만 9.4점 고품질 — 리뷰 증가 전략 + 콘텐츠 자산화 우선', repurposingSuggestions: ['리뷰 발췌 Carousel 시리즈', 'Instagram Stories 시리즈', '블로그 환자 스토리', '광고 소셜프루프'], status: 'pending' },
{ id: 'ir-a3', source: 'social', sourceLabel: 'Instagram TH', type: 'photo', title: 'Instagram @seouli_ps_th 게시물 200개 (5K 팔로워)', description: '태국어 콘텐츠 200개 — Facebook 태국 광고 소재 + LINE 콘텐츠 재활용 가능', repurposingSuggestions: ['Facebook 태국 타겟 광고 소재', 'LINE 태국 메시지 콘텐츠', '@seoulips KR 크로스 태그 시리즈'], status: 'collected' },
{ id: 'ir-a4', source: 'social', sourceLabel: 'Instagram KR', type: 'photo', title: 'Instagram @seoulips 게시물 600개', description: '@seoulips KR 누적 600 게시물 — TH/JP 번역 크로스 배포 원본 소스', repurposingSuggestions: ['TH 번역 → @seouli_ps_th 배포', 'JP 번역 → @seouli_jp 배포', 'Reels 재편집'], status: 'collected' },
{ id: 'ir-a5', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '서울아이 시설·의료진 사진', description: 'seoulips.com 내 시설 사진 + 의료진 프로필 — 다국어 채널 신뢰 콘텐츠 소스', repurposingSuggestions: ['Instagram 시설 투어 Reels', 'YouTube B-roll', '블로그 위치 안내', '다국어 광고 배경'], status: 'collected' },
{ id: 'ir-a6', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 카테고리 3종 설명 (코성형·눈밑지방·리프팅)', description: 'seoulips.com 시술별 상세 설명 — 블로그 SEO 포스트 + 다국어 카드뉴스 소스', repurposingSuggestions: ['블로그 SEO 포스트 소스', '다국어 카드뉴스 텍스트', 'LINE FAQ 자동응답 소스'], status: 'collected' },
{ id: 'ir-a7', source: 'blog', sourceLabel: '네이버 블로그', type: 'text', title: '기존 네이버 블로그 포스트', description: 'blog.naver.com/seoulips 기존 포스트 — SEO 키워드 분석 후 리라이팅 대상', repurposingSuggestions: ['고트래픽 포스트 리라이팅', '키워드 맵 추출', '내부 링크 재구성'], status: 'pending' },
{ id: 'ir-a8', source: 'homepage', sourceLabel: '홈페이지 (제작 필요)', type: 'video', title: '다국어 병원 소개 영상 (신규 제작)', description: '"서울아이 = 안전하고 바른 성형 + KR/TH/JP 환자 환영" 통합 브랜드 영상', repurposingSuggestions: ['YouTube 채널 트레일러', '다국어 Landing Page 히어로 영상', 'Instagram KR/TH/JP 프로필 영상'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '코성형 상담부터 수술까지 — 전문의 Q&A', views: 0, type: 'Long', repurposeAs: ['Shorts 3개 추출', 'Instagram KR Reels', 'TH/JP 번역 배포', 'Blog 임베드'] },
{ title: '눈밑지방 제거 과정 설명', views: 0, type: 'Long', repurposeAs: ['Shorts 2개 추출', 'Reels KR+TH+JP 동시 배포', 'Blog SEO 포스트'] },
{ title: '실리프팅 효과와 지속 기간', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Blog 임베드', '강남언니 프로필 연동'] },
{ title: '코성형 붓기 타임라인 — 1개월 경과', views: 0, type: 'Short', repurposeAs: ['Instagram KR/TH/JP 동시 배포', 'TikTok 크로스포스팅'] },
{ title: '서울아이 서울 방문 가이드 (외국인)', views: 0, type: 'Long', repurposeAs: ['Instagram TH/JP 여행 시리즈', 'LINE 메시지 콘텐츠', 'Facebook 광고 소재'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '155개 YouTube 영상 → EN/TH/JP 자막 추가', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: '자막 추가 영상 30개 (1차)', channel: 'YouTube', description: '기존 영상에 EN/TH 자막 추가 → 글로벌 검색 노출 즉시 10배 확대 (P0)' },
{ format: 'Shorts 50개 추출', channel: 'YouTube / Instagram KR / TH / JP', description: '1분 클립 추출 후 KR/EN/TH/JP 다국어 Shorts — 3개월 분량 확보' },
{ format: 'Instagram TH/JP 버전', channel: 'Instagram TH + JP', description: 'Shorts를 태국어·일본어 번역 버전으로 변환 → 3개 계정 동시 배포' },
],
},
{
sourceVideo: { title: '강남언니 86 리뷰 → 콘텐츠화', views: 86, type: 'Long', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Carousel 시리즈 (월 4회)', channel: 'Instagram KR', description: '리뷰 핵심 발췌 카드뉴스 — 21개월치 소재 확보' },
{ format: 'Blog 환자 스토리', channel: 'Naver Blog', description: '리뷰 → 환자 여정 스토리 블로그 변환 (SEO 포스트)' },
{ format: '광고 소셜프루프', channel: 'Facebook TH / JP', description: '"9.4점 / 86 리뷰의 진심" 메시지 외국인 환자 타겟 광고 소재' },
],
},
{
sourceVideo: { title: '외국인 환자 브이로그 (신규 제작 — TH/JP)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'YouTube 브이로그 (태국어 자막)', channel: 'YouTube', description: '태국 환자 서울 방문 여정 풀 영상 — YouTube 글로벌 노출' },
{ format: 'Instagram TH Reels 시리즈', channel: 'Instagram TH (@seouli_ps_th)', description: '브이로그 클립 → TH Reels 5편 시리즈' },
{ format: 'LINE 일본 콘텐츠', channel: 'LINE', description: 'JP 환자 브이로그 요약 + LINE 상담 CTA — 일본 전환 유도' },
],
},
{
sourceVideo: { title: 'LINE 일본 상담 채널 개설 콘텐츠', views: 0, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'medium',
outputs: [
{ format: '@seouli_jp Instagram Reels', channel: 'Instagram JP', description: 'LINE 개설 공지 Reels + QR코드 삽입' },
{ format: 'YouTube JP 자막 CTA', channel: 'YouTube', description: '모든 영상 엔딩에 LINE 상담 QR코드 추가' },
{ format: 'Instagram TH LINE CTA', channel: 'Instagram TH', description: '@seouli_ps_th Story + 게시물에 LINE 링크 추가' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'ir-wf-001',
title: 'LINE 공식 계정 개설 + 일본어 FAQ 자동응답 설정',
contentType: 'image-text',
channel: 'LINE',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: 'Seoul i Plastic Surgery LINE 공식 채널 오픈',
copy: [
'일본어 자동응답: 코성형 / 눈밑지방 / 리프팅 시술별 FAQ',
'예약 문의 → 코디네이터 연결 플로우',
'@seouli_jp 바이오 + 모든 게시물에 LINE QR코드 삽입',
'오픈 공지 Instagram JP Reels 동시 발행',
],
},
},
{
id: 'ir-wf-002',
title: 'YouTube 기존 영상 EN/TH 자막 1차 30개 추가',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'planning',
videoDraft: {
script: '[자막 추가 대상 우선순위]\n1. 코성형 관련 영상 (조회수 상위)\n2. 눈밑지방 제거 설명 영상\n3. Before/After 케이스 영상\n\n[작업 방식]\n- 기존 자막 없는 영상 → EN 자동 번역 후 수정\n- TH 번역은 네이티브 검수 후 업로드\n- JP 번역은 2차 30개 추가 예정',
shootingGuide: [
'자막 추가 후 YouTube Studio에서 언어 설정 확인',
'제목·설명에 EN/TH/JP 키워드 추가 (SEO)',
'자막 완료 영상은 채널에 핀 게시 or 플레이리스트 상위 노출',
],
duration: '월 10개, 3개월 내 30개 완료',
},
},
{
id: 'ir-wf-003',
title: 'Facebook 신규 페이지 개설 + 픽셀 설치',
contentType: 'image-text',
channel: 'Facebook',
channelIcon: 'facebook',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '"Seoul i Plastic Surgery" Facebook 신규 개설',
copy: [
'기존 비공개 페이지 대체 — 영문 "Seoul i Plastic Surgery" 이름으로 개설',
'seoulips.com 픽셀 설치 → Instagram TH/JP 방문자 리타겟팅',
'태국어·일본어 첫 광고 캠페인 소재: 코성형 Before/After',
'WhatsApp 연동 검토 (동남아 직접 상담)',
],
},
},
{
id: 'ir-wf-004',
title: '강남언니 86 리뷰 Carousel 1차 4편 제작',
contentType: 'image-text',
channel: 'Instagram KR',
channelIcon: 'instagram',
stage: 'ai-draft',
imageTextDraft: {
type: 'cardnews',
headline: '강남언니 9.4점 — 진심 담긴 86개의 이야기',
copy: [
'슬라이드 1: "강남언니 9.4점 / 86 리뷰 감사합니다"',
'슬라이드 2-5: 베스트 리뷰 4개 발췌 (익명화 처리)',
'슬라이드 6: "리뷰 남기기 → 강남언니 프로필 링크"',
],
layoutHint: 'Forest Green 배경, Mint 텍스트 강조, 서울아이 로고 하단',
},
},
{
id: 'ir-wf-005',
title: 'Instagram KR/TH/JP 크로스 배포 루틴 문서화',
contentType: 'image-text',
channel: 'Instagram KR',
channelIcon: 'instagram',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '3개국 Instagram 크로스 배포 SOP',
copy: [
'KR 원본 제작 → EN 번역 → TH 번역 (네이티브 검수) → JP 번역 (네이티브 검수)',
'예약 스케줄러 설정: KR 월요일 → TH 화요일 → JP 수요일 동시 배포',
'해시태그 국가별 최적화 가이드 문서화',
'월 1회 3개 계정 성과 리뷰 루틴 수립',
],
},
},
],
},
};

View File

@ -0,0 +1,507 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
* O2O Clinic ( )
*
* · /
* INFINITH . 6
* ·.
*/
export const mockPlanO2O: MarketingPlan = {
id: 'o2o',
reportId: 'o2o',
clinicName: 'O2O Clinic',
clinicNameEn: 'O2O Plastic Surgery Clinic',
createdAt: '2026-04-14',
targetUrl: 'https://www.o2oclinic.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'Indigo Deep', hex: '#1E1B4B', usage: '주요 헤더·CTA 버튼 — 신뢰·전문성·기술적 정확성 상징' },
{ name: 'Cyan', hex: '#06B6D4', usage: '강조색 — 혁신·연결·글로벌 환영의 시그널' },
{ name: 'Slate Light', hex: '#F8FAFC', usage: '섹션 배경 — 대비되는 깨끗한 여백' },
{ name: 'Slate 900', hex: '#0F172A', usage: '본문 텍스트·고대비 강조' },
{ name: 'Slate 500', hex: '#64748B', usage: '서브 텍스트·메타 정보' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩·섹션 타이틀', sampleText: 'O2O Clinic — 9년의 임상, 2,547개의 진심' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트', sampleText: 'O2O Clinic은 눈·코·윤곽 전문 클리닉입니다' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩·O2O 워드마크', sampleText: 'O2O Clinic — Eye, Nose, Contouring' },
],
logoRules: [
{ rule: 'Indigo + Cyan 그라디언트 워드마크 통일', description: 'O2O 워드마크는 Indigo Deep → Cyan 그라디언트를 모든 채널에서 동일 적용', correct: true },
{ rule: '원형 로고: 화이트 배경 + Indigo 심볼', description: '프로필 사진용 1080×1080 원형 — 화이트 배경 + Indigo "O2O" 심볼', correct: true },
{ rule: '글로벌 채널: 영문 로고 단독', description: '@o2o_clinic_global, Facebook Global 등 영문 채널은 한글 병기 제외', correct: true },
{ rule: '여백 규칙', description: '로고 크기의 30% 이상 여백 확보 — 가독성 + 브랜드 권위', correct: true },
{ rule: '단색 사용 시', description: '단색 적용 시 Indigo Deep (#1E1B4B) 단독, 흰 배경에서만 사용', correct: true },
],
toneOfVoice: {
personality: ['전문적', '신뢰감 있는', '글로벌', '따뜻한', '명확한'],
communicationStyle: '9년의 임상과 2,547개의 검증된 후기를 근거로 하되, 교육적이고 환자 친화적인 어조로 전달합니다. 시술 결과보다 의사결정 과정의 투명성을 강조.',
doExamples: [
'"눈성형은 결과보다 디자인이 결정합니다 — 오투오 원장의 5원칙"',
'"9년의 임상, 2,547개의 진심 — O2O Clinic"',
'"한·영·중·일 상담 가능 — Global Patients Welcome"',
'"강남언니 9.5점이 증명하는 디테일"',
],
dontExamples: [
'"강남 No.1!" — 비교 광고 표현',
'"100% 만족 보장" — 의료광고법 위반 가능성',
'"오늘까지 50% 할인" — 가격 경쟁 톤 지양',
'"인생이 바뀝니다" — 과장 표현 금지',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: 'O2O Indigo+Cyan 원형 로고 1080×1080', bannerSpec: '2560×1440px, Indigo Deep 배경 + Cyan 악센트, "Eye · Nose · Contouring · Since 2017"', bioTemplate: 'O2O Clinic — 눈·코·윤곽 전문\n오투오 원장 · 강남역 | 02-2020-2020\no2oclinic.com', currentStatus: 'correct' },
{ channel: 'Instagram KR', icon: 'instagram', profilePhoto: 'O2O Indigo+Cyan 원형 로고', bannerSpec: 'N/A (하이라이트: 눈성형/코성형/윤곽/리뷰/글로벌 — Indigo+Cyan 톤)', bioTemplate: 'O2O Clinic 공식 · 눈·코·윤곽 전문\n강남역 | 02-2020-2020 · o2oclinic.com', currentStatus: 'correct' },
{ channel: 'Instagram Global', icon: 'instagram', profilePhoto: 'O2O 영문 로고', bannerSpec: 'N/A (Highlights: Eye/Nose/Contouring/Reviews/Booking)', bioTemplate: 'O2O Clinic Global · Eye · Nose · Facial Contouring\nGangnam Seoul · DM for English/Chinese/Japanese consultation', currentStatus: 'correct' },
{ channel: 'Facebook Global', icon: 'facebook', profilePhoto: 'O2O 영문 로고', bannerSpec: '820×312px, Indigo+Cyan 배경 + "Eye · Nose · Contouring · Multilingual"', bioTemplate: 'O2O Clinic — 9 Years of Excellence in Eye, Nose, Facial Contouring\nGangnam Seoul · WhatsApp + DM Welcome', currentStatus: 'correct' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: 'O2O 로고', bannerSpec: '블로그 상단: 워드마크 + 시술 카테고리 메뉴 (눈/코/윤곽)', bioTemplate: 'O2O Clinic 공식 블로그 — 눈·코·윤곽 전문\n오투오 원장 · 강남역 · 9년 임상', currentStatus: 'correct' },
{ channel: 'TikTok', icon: 'video', profilePhoto: 'O2O Indigo+Cyan 원형 로고', bannerSpec: 'N/A', bioTemplate: 'O2O Clinic · 눈·코·윤곽 · 강남역\nReal Cases | DM 상담', currentStatus: 'incorrect' },
],
brandInconsistencies: [
{
field: '브랜드 컬러 적용',
values: [
{ channel: 'YouTube', value: 'Indigo+Cyan 그라디언트 적용 완료', isCorrect: true },
{ channel: 'Instagram KR', value: 'Indigo+Cyan 적용 완료', isCorrect: true },
{ channel: 'Instagram Global', value: 'Indigo+Cyan 적용 완료', isCorrect: true },
{ channel: 'TikTok', value: '단색 화이트 — 브랜드 컬러 미적용', isCorrect: false },
],
impact: '6개 채널 중 5개에서 브랜드 컬러 일관성 우수. TikTok만 제외 — 즉시 수정 시 전 채널 통일성 100%',
recommendation: 'TikTok 프로필 사진을 Indigo+Cyan 원형 로고로 교체 + 채널 아트 통일',
},
{
field: 'CTA 메시지 통일',
values: [
{ channel: 'YouTube', value: '"카카오톡 상담 + 홈페이지"', isCorrect: true },
{ channel: 'Instagram KR', value: '"DM 상담 + 카카오톡"', isCorrect: true },
{ channel: 'Facebook Global', value: '"WhatsApp + DM"', isCorrect: true },
{ channel: 'Naver Blog', value: '"전화 + 카카오톡"', isCorrect: false },
],
impact: 'KR 채널 CTA가 카카오톡 위주로 정착되어 있으나 블로그만 전화 위주 — 모바일 환자 전환 손실 가능',
recommendation: '네이버 블로그 포스트 상단 + 하단 CTA를 "카카오톡 상담 → 전화 백업" 순으로 통일',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '@o2oclinic — 5,840 구독자, 280 영상, 주 2회 안정 업로드',
targetGoal: '20K 구독자 / 12개월, 다국어 자막 50개 추가',
contentTypes: ['오투오 원장 Q&A 롱폼', 'Shorts (기존 영상 클립)', '국제 환자 브이로그 (다국어 자막)'],
postingFrequency: '주 2회 (Long 1 + Shorts 1)',
tone: '교육적 전문가 — 오투오 원장 직접 설명',
formatGuidelines: [
'롱폼: 6~12분, 시술별 시리즈 (눈/코/윤곽)',
'Shorts: 30~60초, 후크 3초 내 + 다국어 자막',
'썸네일: Indigo+Cyan 워터마크 통일',
'국제 환자 브이로그: EN/ZH/JP 자막 필수',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram_kr', channelName: 'Instagram KR', icon: 'instagram',
currentStatus: '@o2o_clinic — 8,210 팔로워, 1,850 게시물, Reels 142개',
targetGoal: '25K 팔로워 / 12개월, Reels 주 5개',
contentTypes: ['Reels (YouTube Shorts 동시 배포)', 'Carousel (강남언니 2,547 리뷰 스토리화)', 'Stories (병원 일상 + Q&A)', '인플루언서 협업 카루셀'],
postingFrequency: '일 1회 + Stories 일 2-3개',
tone: '전문성 + 친근함 — 시술 디테일을 쉽게 풀어내는 톤',
formatGuidelines: [
'Reels: YouTube Shorts와 동시 게시 (자산 재활용 우선)',
'Carousel: 강남언니 리뷰 50개 선별 → 카루셀 시리즈',
'Stories: 눈/코/윤곽 카테고리별 하이라이트 재구성',
'해시태그: #O2OClinic #강남눈성형 #강남코성형 #윤곽수술 #오투오원장',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'instagram_global', channelName: 'Instagram Global', icon: 'instagram',
currentStatus: '@o2o_clinic_global — 6,320 팔로워, 480 게시물, 다국어 Reels',
targetGoal: '18K 팔로워 / 12개월, 의료관광 환자 직접 상담 50건/월',
contentTypes: ['English/Chinese/Japanese subtitled Reels', 'Patient journey Carousel', 'Multilingual FAQ Story'],
postingFrequency: '일 1회 (3개 언어 로테이션)',
tone: 'Professional & welcoming — Korea medical tourism storytelling',
formatGuidelines: [
'KR 콘텐츠 → EN/ZH/JP 번역 자동 배포 파이프라인',
'WhatsApp 상담 CTA를 바이오 + 매 게시물에 삽입',
'Highlights: Eye / Nose / Contouring / Reviews / Booking 5개',
'해시태그: #KoreanPlasticSurgery #GangnamClinic #O2OClinic',
],
priority: 'P0',
customerJourneyStage: 'conversion',
},
{
channelId: 'facebook_global', channelName: 'Facebook Global', icon: 'facebook',
currentStatus: 'O2O Clinic Global — 3,025 팔로워, WhatsApp 연동, Pixel 설치 완료',
targetGoal: '12K 팔로워 + 리타겟팅 ROAS 4x / 6개월',
contentTypes: ['리타겟팅 광고 소재', 'Patient journey 영문 콘텐츠', 'Before/After (consent verified)'],
postingFrequency: '주 2-3회 + 광고 캠페인 상시',
tone: 'Authoritative & warm — 권위와 친근함의 균형',
formatGuidelines: [
'리타겟팅: o2oclinic.com 방문자 픽셀 데이터 활용 (P0)',
'광고 소재 4종 (눈/코/윤곽/Multi) — A/B 테스트 운영',
'WhatsApp CTA 우선 + Messenger 백업',
'월 KPI: ROAS 2.5x → 4.0x 단계 상향',
],
priority: 'P0',
customerJourneyStage: 'conversion',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: 'blog.naver.com/o2oclinic — 주 2회 SEO 포스팅, 키워드 상위 30+',
targetGoal: '주 2회 유지 + 월 30,000 방문자 (12개월)',
contentTypes: ['SEO 시술 가이드', '환자 후기 (강남언니 리뷰 가공)', 'YouTube 영상 임베드 + 텍스트 가이드'],
postingFrequency: '주 2회',
tone: '정보성 전문가 — "강남 눈성형", "코성형 가격", "윤곽수술 후기" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 최적화 + 이미지 10장 + YouTube 임베드',
'CTA: 카카오톡 상담 우선 → 전화 백업으로 통일 (현재 불일치 수정)',
'키워드 맵: "강남 눈성형", "강남 코성형", "윤곽수술 가격", "오투오 원장"',
'월 1회 키워드 성과 분석 + 다음 달 주제 결정',
],
priority: 'P1',
customerJourneyStage: 'consideration',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.5점 / 2,547 리뷰 — 응답률 88%, 강남 상위 10%',
targetGoal: '응답률 95%, 12개월 내 3,800 리뷰',
contentTypes: ['리뷰 응답 (일 단위)', '시술 정보 업데이트', '리뷰 SNS 콘텐츠화'],
postingFrequency: '리뷰 응답: 일 단위 / 정보 업데이트: 월 2회',
tone: '진심 어린 의료진 답변 — 형식적 답변 지양',
formatGuidelines: [
'리뷰 응답률 88% → 95% (3개월) 단계 상향',
'2,547 리뷰 중 100개 선별 → SNS 카드뉴스 자산화',
'부정 리뷰 24시간 내 응답 원칙',
'강남언니 프로필에 다국어 시술 설명 추가',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
{
channelId: 'tiktok', channelName: 'TikTok', icon: 'video',
currentStatus: '@o2oclinic — 3,400 팔로워, Pixel 미설치',
targetGoal: '12K 팔로워 + Pixel 설치 + 광고 ROAS 3x / 6개월',
contentTypes: ['YouTube Shorts 크로스포스팅', '트렌드 챌린지', '병원 비하인드'],
postingFrequency: '주 5회 (YouTube Shorts 동시 배포)',
tone: '가볍고 접근 가능한 — 20-30대 첫 수술 고민층 타겟',
formatGuidelines: [
'TikTok Pixel 즉시 설치 (P0) — 광고 ROAS 측정 가능화',
'280 YouTube 영상 → Shorts 추출 → TikTok 동시 배포',
'트렌딩 사운드 활용 + 자막 필수',
'의료광고법 준수 — 과장 표현 금지',
],
priority: 'P1',
customerJourneyStage: 'awareness',
},
{
channelId: 'kakaotalk', channelName: 'KakaoTalk', icon: 'messageSquare',
currentStatus: '카카오 채널 친구 4,200+, 자동응답 운영',
targetGoal: '친구 10,000+ + 상담 전환율 35%',
contentTypes: ['시술별 자동응답 시나리오', '예약 리마인더', '월 1회 시술 정보 발송'],
postingFrequency: '주 1-2회 메시지 발송',
tone: '따뜻하고 전문적인 1:1 상담 톤',
formatGuidelines: [
'자동응답 시나리오 — 눈/코/윤곽 시술별 분기',
'예약 리마인더 자동 발송',
'한국어 + 영문/중문 옵션 (의료관광 대비)',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '9년 임상의 디테일',
description: '2017년 개원 이래 9년간 축적한 눈·코·윤곽 임상 데이터와 수술 철학을 보여주는 콘텐츠',
relatedUSP: '9 Years of Specialized Expertise',
exampleTopics: ['오투오 원장의 자연스러운 눈성형 5원칙', '코성형 디자인의 3가지 핵심 변수', '윤곽수술이 인상에 미치는 변화', '9년간 변하지 않은 O2O의 원칙 3가지'],
color: '#1E1B4B',
},
{
title: '글로벌 환자 여정',
description: '영문/중문/일문 콘텐츠로 의료관광 환자의 내원 전후 여정을 다큐멘터리화 — 정보 + 신뢰',
relatedUSP: 'Multilingual Medical Tourism Care',
exampleTopics: ['From Bangkok to Seoul — Eye Surgery Journey', '中国患者ソウル鼻整形体験記', '강남에서 회복하기 — 외국인 환자 가이드', 'O2O 외국인 환자 케어 시스템 공개'],
color: '#06B6D4',
},
{
title: '2,547 리뷰의 진심',
description: '강남언니 9.5점 / 2,547 실사용 후기를 콘텐츠 자산으로 전환 — 신뢰 증거 극대화',
relatedUSP: 'Patient-Validated 9.5 Stars',
exampleTopics: ['"눈성형 후 진짜 자연스럽다" — 강남언니 베스트 리뷰 5', '2,547개 리뷰 공통 키워드 분석', '리뷰 만족도 Top 3 시술 — 데이터로 보는 O2O', '리뷰 작성 후기자 인터뷰'],
color: '#0F172A',
},
{
title: '인플루언서 컬래버 시리즈',
description: '뷰티/메디컬 크리에이터 협업 콘텐츠로 신규 환자 도달 — 분기별 5~8명 정기 협업',
relatedUSP: 'Trusted by Beauty Creators',
exampleTopics: ['뷰티 크리에이터 A의 O2O 코성형 후기', '메디컬 크리에이터 B의 윤곽수술 다큐멘터리', '크리에이터 라이브 Q&A', '협업 콘텐츠 비하인드'],
color: '#64748B',
},
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '주 1회', purpose: '오투오 원장 권위 + 9년 임상 증명' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram KR', 'Instagram Global', 'TikTok'], frequency: '주 5개', purpose: '280 영상 자산 재활용, 다채널 도달 확대' },
{ format: 'Carousel (리뷰 스토리)', channels: ['Instagram KR'], frequency: '주 2회', purpose: '2,547 리뷰 → 사회적 증거화' },
{ format: 'Multilingual Subtitled Reels', channels: ['Instagram Global'], frequency: '일 1회', purpose: '글로벌 환자 도달 + WhatsApp 전환' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 2회', purpose: '강남 눈/코/윤곽 검색 상위 노출' },
{ format: 'Influencer Collab Carousel', channels: ['Instagram KR'], frequency: '월 2회', purpose: '신규 팔로워 유입 + 권위 강화' },
{ format: 'Retargeting Ad Creative', channels: ['Facebook Global', 'Instagram Global'], frequency: '월 4-6개', purpose: '글로벌 의료관광 ROAS 향상' },
],
workflow: [
{ step: 1, name: '주제 발굴', description: '시술별 검색 키워드 + 콘텐츠 필러 매칭 + 인플루언서 협업 브리핑', owner: '마케팅 매니저', duration: '2일' },
{ step: 2, name: '제작 / 추출', description: 'AI 초안 + 280 영상 아카이브에서 Shorts 추출 + 인플루언서 컬래버 촬영', owner: 'AI + 콘텐츠팀', duration: '3-5일' },
{ step: 3, name: '의료 검수', description: '오투오 원장 + 의료진 검토 (의료광고법 포함)', owner: '의료진', duration: '1일' },
{ step: 4, name: '다국어 번역', description: 'EN/ZH/JP 자막·캡션 (Global 계정 동시 배포용)', owner: '번역팀', duration: '1-2일' },
{ step: 5, name: '비주얼 마감', description: 'Indigo+Cyan 워터마크 통일 + 채널별 포맷 최적화', owner: '디자인팀', duration: '1일' },
{ step: 6, name: '배포 & 추적', description: '전 채널 동시 배포 + UTM/픽셀 추적 + 강남언니 상담 연계', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '1개 오투오 원장 롱폼 영상 (10분) 또는 인플루언서 협업 콘텐츠 1편',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 풀 영상 (KR 기본 + EN/ZH/JP 자막)' },
{ format: 'Shorts 5개', channel: 'YouTube / TikTok', description: '핵심 구간 30-60초 추출 → 동시 배포' },
{ format: 'Reels 5개', channel: 'Instagram KR + Global', description: 'Shorts → KR 원본 + 다국어 자막 버전 동시 배포' },
{ format: 'Carousel 2개', channel: 'Instagram KR', description: '시술 가이드 카드뉴스로 재구성' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '영상 스크립트 → 2,000자 SEO 포스트 (영상 임베드)' },
{ format: 'Stories 5개', channel: 'Instagram KR + Global', description: '촬영 비하인드 + Q&A 스니펫' },
{ format: 'Ad Creative 2개', channel: 'Facebook Global', description: '글로벌 리타겟팅용 영문/중문 광고 소재' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: 9년 임상 — 눈성형 집중',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 오투오 원장의 자연스러운 눈성형 5원칙' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 눈성형 Before/After 30대 케이스' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 쌍수 vs 눈매교정 60초 비교' },
{ dayOfWeek: 2, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '강남 눈성형 추천 기준 — 9년 임상이 알려줍니다' },
{ dayOfWeek: 3, channel: 'Instagram Global', channelIcon: 'instagram', contentType: 'social', title: 'Reels (EN/ZH): Eye Surgery Recovery Timeline' },
{ dayOfWeek: 4, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 베스트 리뷰 5선 — 눈성형' },
{ dayOfWeek: 5, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(EN): Global 리타겟팅 — Eye Surgery 캠페인 런칭' },
],
},
{
weekNumber: 2, label: 'Week 2: 코성형 + 인플루언서 컬래버 1차',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 코성형 디자인의 3가지 핵심 변수' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: '인플루언서 컬래버 1편: 뷰티 크리에이터 A의 O2O 코성형' },
{ dayOfWeek: 2, channel: 'Instagram Global', channelIcon: 'instagram', contentType: 'social', title: 'Reels (JP): 鼻整形のリカバリータイムライン' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 붓기 30일 타임랩스' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '코성형 가격 vs 가치 — 9년 임상 기준 가이드' },
{ dayOfWeek: 4, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 코성형 디자인 시뮬레이션 비하인드' },
{ dayOfWeek: 5, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(EN/ZH): Nose Surgery 패키지 광고' },
],
},
{
weekNumber: 3, label: 'Week 3: 윤곽수술 + 글로벌 환자 여정',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 윤곽수술이 인상에 미치는 변화 — Real Cases' },
{ dayOfWeek: 1, channel: 'Instagram Global', channelIcon: 'instagram', contentType: 'social', title: 'Carousel (EN): From Bangkok to Seoul — Patient Journey' },
{ dayOfWeek: 2, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: V라인 윤곽수술 회복 일기 (동의 케이스)' },
{ dayOfWeek: 3, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 윤곽수술 Q&A — 가장 많이 묻는 질문 5' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '윤곽수술 후기 가이드 — 강남언니 2,547 리뷰가 알려준 것' },
{ dayOfWeek: 4, channel: 'Instagram Global', channelIcon: 'instagram', contentType: 'social', title: 'Story (JP): O2O予約フロー — 日本語ご相談' },
{ dayOfWeek: 5, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(JP): Facial Contouring Japan 타겟팅' },
],
},
{
weekNumber: 4, label: 'Week 4: 리뷰 자산 + 인플루언서 2차 + 월간 정리',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 9년간 변하지 않은 O2O의 원칙 3가지 — 오투오 원장 인터뷰' },
{ dayOfWeek: 1, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 2,547 리뷰 공통 키워드 분석 — 자연스럽다 / 디테일 / 친절' },
{ dayOfWeek: 2, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: '인플루언서 컬래버 2편: 메디컬 크리에이터 B의 윤곽수술 다큐' },
{ dayOfWeek: 3, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 강남언니 9.5점 달성 감사 — 30초 메시지' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '월간 리뷰 — 4월 O2O Clinic 콘텐츠 베스트 7선' },
{ dayOfWeek: 4, channel: 'Instagram KR', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 다음 달 예고 — 5월 O2O 콘텐츠 시리즈 오픈' },
{ dayOfWeek: 5, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(Multi): 월말 글로벌 상담 CTA 캠페인' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 12, color: '#1E1B4B' },
{ type: 'blog', label: '블로그', count: 4, color: '#06B6D4' },
{ type: 'social', label: 'Instagram KR/Global', count: 12, color: '#0F172A' },
{ type: 'ad', label: '광고 (Facebook Global)', count: 4, color: '#64748B' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'o2o-a1', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: 'YouTube 영상 280개 아카이브 (@o2oclinic)', description: '9년간 누적 280개 영상 — Shorts 추출 + 다국어 자막 추가 우선 자산', repurposingSuggestions: ['Shorts 100개 추출 (P0)', 'EN/ZH/JP 자막 50개 추가', 'Instagram Reels 변환', 'Blog 임베드'], status: 'collected' },
{ id: 'o2o-a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 환자 리뷰 2,547건 (9.5점)', description: '2,547개 검증된 후기 — 시술별 분류 후 SNS 콘텐츠 자산화', repurposingSuggestions: ['리뷰 100개 → Carousel 시리즈', 'Story 카드 자동 발행 시스템', '광고 소셜프루프', 'Blog 환자 스토리'], status: 'pending' },
{ id: 'o2o-a3', source: 'social', sourceLabel: 'Instagram KR', type: 'photo', title: 'Instagram @o2o_clinic 게시물 1,850개', description: 'KR 메인 계정 누적 1,850 게시물 — 고성과 콘텐츠 → 다국어 변환 원본', repurposingSuggestions: ['고성과 게시물 → Reel 변환', 'Global 계정 다국어 배포', '광고 소재 추출'], status: 'collected' },
{ id: 'o2o-a4', source: 'social', sourceLabel: 'Instagram Global', type: 'photo', title: 'Instagram @o2o_clinic_global 다국어 콘텐츠', description: '480개 다국어 콘텐츠 — Facebook 광고 소재 + LINE/WeChat 콘텐츠 재활용', repurposingSuggestions: ['Facebook 글로벌 광고 소재', 'LINE/WeChat 콘텐츠 재활용', 'YouTube 다국어 시리즈 소스'], status: 'collected' },
{ id: 'o2o-a5', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: 'O2O Clinic 시설·의료진 사진', description: 'o2oclinic.com 시설 사진 + 오투오 원장 외 의료진 8명 프로필', repurposingSuggestions: ['Instagram 시설 투어 Reel', 'YouTube B-roll', '블로그 위치 안내', '광고 배경'], status: 'collected' },
{ id: 'o2o-a6', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '시술 카테고리 3종 설명 (눈·코·윤곽)', description: 'o2oclinic.com 시술별 상세 설명 — 블로그 SEO + 다국어 카드뉴스 소스', repurposingSuggestions: ['블로그 SEO 포스트 소스', '다국어 카드뉴스 텍스트', 'WhatsApp 자동응답 FAQ 소스'], status: 'collected' },
{ id: 'o2o-a7', source: 'blog', sourceLabel: '네이버 블로그', type: 'text', title: '네이버 블로그 포스트 200+개', description: '주 2회 정기 발행 누적 — SEO 키워드 상위 노출 30+개', repurposingSuggestions: ['고트래픽 포스트 → Reel 변환', '키워드 맵 확장', '내부 링크 재구성'], status: 'collected' },
{ id: 'o2o-a8', source: 'social', sourceLabel: '인플루언서 협업', type: 'video', title: '뷰티/메디컬 크리에이터 협업 영상 (제작 필요)', description: '분기별 5~8명 정기 협업 시스템 — 신규 도달 + 권위 강화', repurposingSuggestions: ['Instagram 컬래버 카루셀', 'YouTube 협업 시리즈', 'TikTok 크로스포스팅', '광고 소셜프루프'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '오투오 원장이 알려주는 자연스러운 눈성형 5원칙', views: 84200, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Instagram Reels (KR + Global)', 'Blog 임베드', 'Carousel 2개'] },
{ title: '코성형 붓기, 솔직히 얼마나 가나요? — 30일 타임라인', views: 67500, type: 'Long', repurposeAs: ['Shorts 3개 추출', 'TikTok 크로스포스팅', 'Reels 변환', 'Blog SEO 포스트'] },
{ title: 'V라인 윤곽수술 회복 일기 — 환자 동의 케이스', views: 52300, type: 'Long', repurposeAs: ['Shorts 4개 추출', 'Carousel 시리즈', 'Global 계정 EN 자막'] },
{ title: '쌍수 vs 눈매교정 차이 60초', views: 124000, type: 'Short', repurposeAs: ['Instagram KR + Global 동시 배포', 'TikTok 크로스포스팅', 'Story 시리즈'] },
{ title: '코끝 성형 전후 — Real Case', views: 98700, type: 'Short', repurposeAs: ['Reels 변환', 'TikTok 트렌딩 사운드 추가', '광고 소재 활용'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '오투오 원장 롱폼 인터뷰 (신규 제작)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개', channel: 'YouTube / TikTok', description: '"9년의 원칙" 핵심 구간 30-60초 클립' },
{ format: 'Reels 5개 (KR + Global)', channel: 'Instagram KR + Global', description: 'Shorts 다국어 자막 버전 동시 배포' },
{ format: 'Carousel 2개', channel: 'Instagram KR', description: '오투오 원장 철학 카드뉴스화' },
{ format: 'Blog Post 1개', channel: 'Naver Blog', description: '인터뷰 풀 스크립트 → 2,500자 SEO 포스트' },
{ format: '광고 소재 2개', channel: 'Facebook Global', description: '"9 Years of Excellence" 영문/중문 광고' },
],
},
{
sourceVideo: { title: '강남언니 2,547 리뷰 → 콘텐츠화', views: 2547, type: 'Long', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Carousel 시리즈 (월 8회)', channel: 'Instagram KR', description: '시술별 베스트 리뷰 카드뉴스 — 3년치 콘텐츠 자산' },
{ format: 'Story 자동 발행', channel: 'Instagram KR + Global', description: '매주 새 리뷰 → Story 자동 발행 파이프라인' },
{ format: '광고 소셜프루프', channel: 'Facebook Global', description: '"2,547 verified reviews" 영문 광고 소재' },
{ format: 'Blog 환자 스토리', channel: 'Naver Blog', description: '리뷰 → 환자 여정 스토리 블로그 변환' },
],
},
{
sourceVideo: { title: '인플루언서 컬래버 시리즈 (신규 제작 — 분기 5~8명)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'high',
priority: 'high',
outputs: [
{ format: '컬래버 카루셀', channel: 'Instagram KR', description: '인플루언서별 1편 카루셀 + 비하인드 Story' },
{ format: 'YouTube 협업 시리즈', channel: 'YouTube', description: '인플루언서 게스트 인터뷰 시리즈' },
{ format: 'TikTok 크로스포스팅', channel: 'TikTok', description: '협업 영상 클립 → TikTok 노출 확대' },
{ format: '광고 소재 활용', channel: 'Facebook + Instagram', description: '컬래버 콘텐츠 → 광고 리타겟팅 소재' },
],
},
{
sourceVideo: { title: '글로벌 환자 여정 다큐 (신규 제작 — 분기 1편)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'medium',
outputs: [
{ format: 'YouTube 다큐 (다국어 자막)', channel: 'YouTube', description: '태국·일본·중국 환자 여정 풀 영상 — 글로벌 노출' },
{ format: 'Instagram Global Reels 시리즈', channel: 'Instagram Global', description: '여정 클립 → Global Reels 5편 시리즈' },
{ format: 'Facebook 광고', channel: 'Facebook Global', description: '국가별 타겟팅 광고 소재 (TH/JP/CN)' },
{ format: 'WhatsApp 콘텐츠', channel: 'WhatsApp 자동응답', description: '여정 요약 + 상담 CTA — 글로벌 환자 1차 응대' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'o2o-wf-001',
title: 'TikTok Pixel 설치 + 1차 광고 캠페인 런칭',
contentType: 'image-text',
channel: 'TikTok',
channelIcon: 'video',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: 'TikTok Pixel 설치 — ROAS 측정 시작',
copy: [
'o2oclinic.com에 TikTok Pixel 코드 설치 (GTM 통한 배포)',
'광고 캠페인 v1: 눈성형 Before/After Shorts → Awareness',
'광고 캠페인 v2: 인플루언서 컬래버 → Consideration',
'주간 ROAS 모니터링 + 예산 재배분 SOP 문서화',
],
},
},
{
id: 'o2o-wf-002',
title: 'YouTube 기존 영상 EN/ZH 자막 1차 30개 추가',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'planning',
videoDraft: {
script: '[자막 추가 우선순위]\n1. 조회수 상위 10개 (눈성형 5원칙, 코성형 타임라인 등)\n2. 글로벌 검색 키워드 일치 영상 10개\n3. 인플루언서 컬래버 영상 10개\n\n[작업 방식]\n- EN 자막: AI 자동 → 네이티브 검수\n- ZH 자막: 중국어 전문 번역사 의뢰\n- YouTube Studio 다국어 제목·설명 동시 적용',
shootingGuide: [
'자막 완료 영상 → 채널 플레이리스트 "Multilingual" 신규 생성',
'제목·설명에 EN/ZH 키워드 추가 (SEO)',
'월간 자막 추가 KPI: 10개 → 누적 50개 (3개월)',
],
duration: '3개월 누적 50개',
},
},
{
id: 'o2o-wf-003',
title: 'Facebook Global 리타겟팅 광고 캠페인 v1',
contentType: 'image-text',
channel: 'Facebook Global',
channelIcon: 'facebook',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: 'Facebook 리타겟팅 — Pixel 활용 즉시 ROAS 향상',
copy: [
'o2oclinic.com 방문자 픽셀 데이터 활용 — 30일 lookback',
'광고 소재 4종: 눈/코/윤곽/Multi',
'타겟팅: 영문 페이지 방문자 + Instagram Global 팔로워',
'WhatsApp CTA 우선 + Messenger 백업',
'주간 ROAS 리뷰 + 소재 A/B 테스트 운영',
],
},
},
{
id: 'o2o-wf-004',
title: '인플루언서 협업 SOP 문서화 + 1차 5명 컨택',
contentType: 'image-text',
channel: 'Instagram KR',
channelIcon: 'instagram',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '인플루언서 협업 시스템화 — 분기 5~8명 정기 협업',
copy: [
'협업 SOP: 후보 선정 → 컨택 → 계약 → 촬영 → 콘텐츠 발행 → 성과 측정',
'1차 후보군: 뷰티 크리에이터 3명 + 메디컬 크리에이터 2명',
'협업 콘텐츠 패키지: Instagram 카루셀 + Story + Reel 1세트',
'계약: 의료광고법 준수 가이드 사전 공유 필수',
],
},
},
{
id: 'o2o-wf-005',
title: '강남언니 2,547 리뷰 자동 카드뉴스 시스템',
contentType: 'image-text',
channel: 'Instagram KR',
channelIcon: 'instagram',
stage: 'ai-draft',
imageTextDraft: {
type: 'cardnews',
headline: '2,547개의 진심 — 강남언니 9.5점이 증명합니다',
copy: [
'슬라이드 1: "강남언니 9.5점 / 2,547 리뷰 — 감사합니다"',
'슬라이드 2-5: 시술별 베스트 리뷰 4개 발췌 (익명화 처리)',
'슬라이드 6: "리뷰 작성 → 강남언니 프로필 링크"',
'주 2회 자동 발행 — 신규 리뷰 RSS 모니터링',
],
layoutHint: 'Indigo Deep 배경, Cyan 강조 텍스트, O2O 워드마크 하단 고정',
},
},
],
},
};

View File

@ -0,0 +1,460 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
*
*
* (`mockReport_ts.ts` 2026-04-14 ):
* - YouTube TV @TV-jm9dy: 8K , 715 , 1~2
* - Instagram @tsprs_official: 2,626 , Reels 60
* - Facebook @tsprs: 3,900 ( )
* - @tsprs:
* - :
* - : 9.5/12,509
* - 2010 16, , ·· , "리얼모델"
* - : "지금, 예뻐져라!"
* - Primary: Dark Navy (#1A1A2E), Accent: Crimson (#E94560)
*/
export const mockPlanTs: MarketingPlan = {
id: 'ts',
reportId: 'ts',
clinicName: '티에스성형외과',
clinicNameEn: 'TS Plastic Surgery',
createdAt: '2026-04-14',
targetUrl: 'https://www.tsprs.com',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'TS Dark Navy', hex: '#1A1A2E', usage: '공식 로고 메인, 헤딩, 강조 텍스트' },
{ name: 'TS Crimson', hex: '#E94560', usage: '로고 악센트, CTA 포인트, 리얼모델 강조' },
{ name: 'Off White', hex: '#F8F8F8', usage: '배경, 카드, 여백' },
{ name: 'Deep Navy Text', hex: '#0F0F1A', usage: '본문 텍스트' },
{ name: 'Mid Gray', hex: '#6B7280', usage: '서브 텍스트, 메타 정보' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀', sampleText: '지금, 예뻐져라! — 티에스성형외과' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트', sampleText: '리얼모델로 증명하는 티에스' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩', sampleText: 'TS Plastic Surgery' },
],
logoRules: [
{ rule: 'Dark Navy+Crimson 공식 로고 통일', description: '티에스성형외과 공식 로고를 모든 채널에서 동일 사용', correct: true },
{ rule: '"지금, 예뻐져라!" 슬로건 전 채널 통일', description: '슬로건을 바이오·배너·콘텐츠 전반에 일관되게 적용', correct: false },
{ rule: '리얼모델 썸네일 시스템', description: '리얼모델 Before/After 썸네일 — Dark Navy 배경 + Crimson 워드마크 워터마크 통일', correct: false },
{ rule: '원형 프로필 1080×1080 통일', description: '전 채널 프로필 이미지 고해상도 원형 버전 통일', correct: false },
],
toneOfVoice: {
personality: ['에너지 있는', '결과 중심', '리얼한', '친근한 전문가', '트렌디'],
communicationStyle: '"지금, 예뻐져라!"는 단순한 슬로건이 아닙니다. 리얼모델로 증명하는 티에스의 결과 중심 철학입니다. 과장 없이 실제 환자 결과로 설득합니다.',
doExamples: [
'"리얼모델이 증명합니다 — 지금, 예뻐져라!"',
'"강남언니 9.5점 · 12,509 리뷰 — 결과로 말합니다"',
'"티에스TV 715개 영상, 눈·코·가슴 모든 것"',
'"신사역 5분 — 지금 예약하세요"',
],
dontExamples: [
'"강남 최고 할인!"',
'"연예인 선택 병원"',
'"100% 만족 보장"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: 'TS Dark Navy 원형 로고 1080×1080', bannerSpec: '2560×1440px, Dark Navy 배경 + Crimson 악센트, "지금, 예뻐져라! 티에스TV"', bioTemplate: '지금, 예뻐져라! 티에스성형외과\n눈·코·가슴·리프팅 리얼모델 채널\n02-512-7580 | tsprs.com', currentStatus: 'incorrect' },
{ channel: 'Instagram', icon: 'instagram', profilePhoto: 'TS Dark Navy 원형 로고', bannerSpec: 'N/A (하이라이트: 눈성형/코성형/가슴성형/리얼모델/이벤트 — Crimson 톤)', bioTemplate: '지금, 예뻐져라! 티에스성형외과 공식\n신사역 | 02-512-7580\ntsprs.com', currentStatus: 'incorrect' },
{ channel: 'Facebook', icon: 'facebook', profilePhoto: 'TS Dark Navy 원형 로고', bannerSpec: '820×312px, Dark Navy+Crimson, "지금, 예뻐져라! 리얼모델로 증명"', bioTemplate: '티에스성형외과 공식 — 강남 신사역\n눈·코·가슴 리얼모델 시스템', currentStatus: 'incorrect' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: 'TS 로고', bannerSpec: '블로그 상단: 로고 + 리얼모델 카테고리 메뉴', bioTemplate: '지금, 예뻐져라! 티에스성형외과 공식 블로그\n눈·코·가슴·리프팅 리얼모델 후기', currentStatus: 'incorrect' },
{ channel: 'TikTok', icon: 'video', profilePhoto: 'TS Dark Navy 원형 로고', bannerSpec: 'N/A', bioTemplate: '지금, 예뻐져라! 티에스성형외과\n신사역 | 02-512-7580', currentStatus: 'missing' },
],
brandInconsistencies: [
{
field: '리얼모델 콘텐츠 크로스채널 미활용',
values: [
{ channel: 'YouTube', value: '티에스TV — 리얼모델 시리즈 있음', isCorrect: true },
{ channel: 'Instagram', value: 'Reels 60개 — 리얼모델 하이라이트 미완성', isCorrect: false },
{ channel: 'Naver Blog', value: '리얼모델 연계 포스팅 부족', isCorrect: false },
{ channel: '연탐 카페', value: '후기 있으나 SNS 연동 없음', isCorrect: false },
],
impact: '리얼모델 콘텐츠가 YouTube에서 강점이나 Instagram·Blog·카페 연동 체계 없음',
recommendation: 'YouTube 리얼모델 → Instagram Reel → Blog 임베드 → 카페 후기 연동 파이프라인 구축',
},
{
field: 'SEO 최적화 현황',
values: [
{ channel: 'YouTube 타이틀', value: 'SEO 메타데이터 최적화 미흡', isCorrect: false },
{ channel: 'YouTube 썸네일', value: '통일 시스템 없음', isCorrect: false },
{ channel: 'Naver Blog', value: '업로드 있으나 키워드 전략 미흡', isCorrect: false },
],
impact: '715개 영상 대비 구독자 8K — SEO·썸네일 최적화만으로 즉각 개선 가능',
recommendation: 'YouTube 715개 영상 SEO 메타데이터 일괄 최적화 + 리얼모델 썸네일 시스템 구축',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube (티에스TV)', icon: 'youtube',
currentStatus: '티에스TV — 8K 구독자, 715 영상, 주 1~2회 업로드',
targetGoal: '20K 구독자 / 12개월, 주 3회 업로드 + SEO 전면 최적화',
contentTypes: ['리얼모델 Before/After 롱폼', 'Shorts (715 영상 재편집)', 'SEO 최적화 타이틀 시리즈'],
postingFrequency: '주 3회 (Shorts 2 + 롱폼 1)',
tone: '"지금, 예뻐져라!" — 리얼모델 에너지 + 전문의 신뢰',
formatGuidelines: [
'715개 영상 SEO 메타데이터 일괄 최적화 (P0)',
'썸네일: Dark Navy 배경 + Crimson 워드마크 + 리얼모델 Before/After 통일',
'타이틀 패턴: "강남 눈코가슴 리얼모델 ___"',
'Shorts: 715 영상에서 200개 추출 목표',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram', channelName: 'Instagram', icon: 'instagram',
currentStatus: '@tsprs_official — 2,626 팔로워, Reels 60개 (확장 여지)',
targetGoal: '15K 팔로워 / 12개월, Reels 100개+ 달성',
contentTypes: ['Reels (YouTube Shorts 동시)', '리얼모델 Before/After Carousel', '연탐 카페 후기 카루셀'],
postingFrequency: '일 1회 + Stories 일 2개',
tone: '"지금, 예뻐져라!" — 에너지 있는 리얼모델 중심',
formatGuidelines: [
'Reels: YouTube Shorts 동시 배포',
'하이라이트: 눈성형/코성형/가슴성형/리얼모델/이벤트 재구성',
'연탐 카페 후기 → Instagram Carousel로 재편집',
'해시태그: #티에스성형외과 #리얼모델 #지금예뻐져라 #강남눈코가슴',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'facebook', channelName: 'Facebook', icon: 'facebook',
currentStatus: '@tsprs — 3,900 팔로워, 활성도 낮음',
targetGoal: 'Instagram 크로스포스팅 자동화 + Pixel 리타겟 세팅',
contentTypes: ['Instagram 자동 크로스포스팅', '리타겟 광고 소재'],
postingFrequency: '주 2-3회 (자동 연동)',
tone: '리얼모델 결과 중심 — 20~40대 여성 타겟',
formatGuidelines: [
'Instagram 콘텐츠 자동 연동',
'Pixel 광고: 눈·코·가슴 관심사 타겟',
'카카오톡 상담 버튼 전 페이지 플로팅',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '@tsprs — 업로드 있으나 빈도·키워드 전략 미흡',
targetGoal: '주 3회로 상향, 12개월 내 월 20,000 방문자',
contentTypes: ['리얼모델 SEO 포스트', 'YouTube 영상 임베드+설명', '눈·코·가슴 시술 가이드'],
postingFrequency: '주 3회',
tone: '"강남 눈코가슴 리얼모델", "티에스성형외과 후기" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 최적화',
'리얼모델 YouTube 영상 임베드 + 텍스트 설명',
'핵심 키워드: "강남 눈성형", "티에스성형외과", "리얼모델 성형"',
'연탐 카페 후기 → 블로그 변환 연동',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'naver_cafe', channelName: '연탐 네이버 카페', icon: 'users',
currentStatus: '연탐 카페 운영 중 — SNS 연동 미흡',
targetGoal: '카페 후기 SNS 연동 파이프라인 구축',
contentTypes: ['카페 후기 → Instagram Carousel 변환', '시술별 Q&A 활성화'],
postingFrequency: '주 2회 (Q&A 답변 + 후기 공유)',
tone: '친밀하고 소통하는 — 커뮤니티 매니저 톤',
formatGuidelines: [
'카페 베스트 후기 → Instagram·Blog 재편집 파이프라인',
'시술별 Q&A 게시판 활성화 (눈/코/가슴)',
'카페 후기 작성 인센티브 제도 검토',
],
priority: 'P1',
customerJourneyStage: 'loyalty',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.5점/10, 12,509 리뷰 — 응답 전략 미흡',
targetGoal: '리뷰 응답률 80%, 15,000 리뷰 / 12개월',
contentTypes: ['리뷰 응답', '시술 정보 최신화'],
postingFrequency: '리뷰 응답 일 단위',
tone: '진심 어린 의료진 답변',
formatGuidelines: [
'응답률 50%(3개월) → 80%(12개월)',
'12,509 리뷰 중 50개 선별 → SNS Carousel',
'부정 리뷰 24시간 내 응답',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
{
channelId: 'tiktok', channelName: 'TikTok (신규)', icon: 'video',
currentStatus: '계정 없음',
targetGoal: '10K 팔로워 / 12개월 — 20~30대 첫 수술 고민층',
contentTypes: ['YouTube Shorts 크로스포스팅', '리얼모델 Before/After 틱톡'],
postingFrequency: '주 5회 (Shorts 동시 배포)',
tone: '"지금, 예뻐져라!" — 트렌디, 에너지 있는',
formatGuidelines: [
'715 영상 Shorts 추출 → TikTok 동시',
'트렌딩 사운드 + 자막 필수',
'의료광고법 준수',
],
priority: 'P1',
customerJourneyStage: 'awareness',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '리얼모델로 증명',
description: '"지금, 예뻐져라!" 슬로건의 핵심 — 실제 리얼모델 Before/After 결과 중심 콘텐츠',
relatedUSP: 'Real Model Verification System',
exampleTopics: ['리얼모델 눈성형 6개월 후 솔직 후기', '리얼모델 코성형 과정 브이로그', '가슴성형 리얼모델 — 보형물 선택부터 회복까지'],
color: '#E94560',
},
{
title: '눈·코·가슴 전문성',
description: '티에스의 3대 핵심 시술 — 각 시술별 심층 교육 콘텐츠',
relatedUSP: 'Eyes, Nose & Breast Expertise',
exampleTopics: ['눈성형 유형별 가이드', '코성형 재수술 예방 팁', '가슴 보형물 종류와 선택'],
color: '#1A1A2E',
},
{
title: '12,509 리뷰의 신뢰',
description: '강남언니 9.5점 12,509 리뷰와 연탐 카페 후기를 콘텐츠화',
relatedUSP: 'Verified Patient Reviews',
exampleTopics: ['강남언니 베스트 리뷰 카루셀', '연탐 카페 후기 TOP 10', '리얼모델 수술 후 6개월 추적'],
color: '#6B7280',
},
{
title: 'SEO & 검색 유입',
description: '715개 영상 자산의 SEO 최적화와 블로그 검색 상위 전략',
relatedUSP: 'Search Engine Visibility',
exampleTopics: ['"강남 눈성형 추천" 검색 1위 전략', '티에스성형외과 블로그 키워드 맵', 'YouTube SEO 타이틀 패턴'],
color: '#F8F8F8',
},
],
typeMatrix: [
{ format: 'YouTube Long-form (리얼모델)', channels: ['YouTube'], frequency: '월 4편', purpose: '리얼모델 케이스 — 결과 신뢰 구축' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 5개', purpose: '715 영상 자산 재활용, 20~40대 도달' },
{ format: 'Carousel (리뷰+후기)', channels: ['Instagram'], frequency: '주 2회', purpose: '12,509 강남언니 + 연탐 카페 사회적 증거화' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 3회', purpose: '"강남 눈코가슴" 검색 상위 확보' },
{ format: 'Stories', channels: ['Instagram'], frequency: '일 2개', purpose: '리얼모델 일상·시술 비하인드' },
{ format: 'Ad Creative', channels: ['Facebook', 'Instagram'], frequency: '월 4개', purpose: '20~40대 여성 타겟 리타겟' },
],
workflow: [
{ step: 1, name: 'SEO 일괄 최적화', description: '715개 YouTube 영상 타이틀·설명·태그 SEO 재작성', owner: 'SEO 담당 + AI', duration: '2주 (1회성)' },
{ step: 2, name: '리얼모델 기획', description: '신규 리얼모델 모집 + 케이스 선정 (눈/코/가슴 각 1건)', owner: '마케팅 매니저', duration: '2주' },
{ step: 3, name: '콘텐츠 제작', description: '리얼모델 촬영 + Shorts 추출 + 카루셀 제작', owner: '콘텐츠 팀', duration: '3-5일' },
{ step: 4, name: '의료 검수', description: '의료진 정확성 + 광고법 체크', owner: '의료진', duration: '1일' },
{ step: 5, name: '비주얼 마감', description: 'Dark Navy+Crimson 썸네일 통일, 자막', owner: '디자인', duration: '1일' },
{ step: 6, name: '크로스채널 배포', description: 'YouTube + Instagram + TikTok + Blog 동시 + UTM', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '리얼모델 눈성형 브이로그 롱폼 (10분)',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '리얼모델 풀 브이로그 (Dark Navy 썸네일)' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '수술 전·중·후 핵심 구간 클립 3채널 동시' },
{ format: 'Carousel', channel: 'Instagram', description: '리얼모델 Before/After + 후기 카드뉴스' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '리얼모델 브이로그 → SEO 리뷰 포스트 (영상 임베드)' },
{ format: '연탐 카페 공유', channel: 'Naver Cafe', description: '카페 시술 후기 게시판 공유' },
{ format: 'Ad Creative', channel: 'Facebook / Instagram', description: '리얼모델 하이라이트 + "지금, 예뻐져라!" CTA' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: SEO 최적화 착수 & 리얼모델 시동',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '리얼모델 눈성형 브이로그 #1 — "지금, 예뻐져라!" 티에스TV' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: '프로필 리뉴얼 + 리얼모델 Reel #1 (눈성형 전후)' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 눈성형 Q&A — 쌍꺼풀 vs 비절개' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '강남 눈성형 가이드 — 티에스 리얼모델이 증명합니다' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 12,509 리뷰 베스트 5' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 가슴성형 보형물 Q&A' },
{ dayOfWeek: 5, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '티에스성형외과 후기 — 강남언니 9.5점의 비밀' },
],
},
{
weekNumber: 2, label: 'Week 2: 코성형 리얼모델 주간',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 리얼모델 코성형 — 상담부터 회복까지' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 코성형 리얼모델 Before/After' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 재수술 피하는 법' },
{ dayOfWeek: 2, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '코성형 종류와 선택 — 티에스 리얼모델 케이스' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 연탐 카페 코성형 베스트 후기' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 6개월 후 — 리얼 후기' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 코성형 리얼모델 상담 CTA' },
],
},
{
weekNumber: 3, label: 'Week 3: 가슴성형 리얼모델 주간',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 리얼모델 가슴성형 — 보형물 선택 가이드' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 가슴성형 리얼모델 Before/After' },
{ dayOfWeek: 1, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '가슴성형 보형물 종류 완전 가이드 — 티에스 리얼모델' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 가슴성형 회복 기간 현실' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 가슴·눈·코 동시 리얼모델 비교' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 지금, 예뻐져라! — 이달의 리얼모델' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '가슴성형 연탐 카페 후기 모음 — 티에스성형외과' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 & 카카오 상담 강화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 티에스성형외과 — 리얼모델로 증명하는 16년' },
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '첫 성형 상담 준비 — 티에스성형외과 신사역 가이드' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 신사역 티에스성형외과 시설 투어' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 이달의 리얼모델 TOP 3' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 카카오톡 상담 예약 3단계' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고: 월말 CTA — "지금, 예뻐져라!"' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '강남언니 12,509 리뷰 — 티에스성형외과 9.5점의 증거' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 14, color: '#1A1A2E' },
{ type: 'blog', label: '블로그', count: 8, color: '#E94560' },
{ type: 'social', label: 'Instagram', count: 8, color: '#6B7280' },
{ type: 'ad', label: '광고', count: 2, color: '#F8F8F8' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'youtube', sourceLabel: '티에스TV', type: 'video', title: '715개 영상 아카이브', description: '2018년 이래 눈·코·가슴 716개 영상 — SEO 최적화 + Shorts 추출 우선', repurposingSuggestions: ['SEO 일괄 최적화 (P0)', 'Shorts 200개 추출', 'Instagram Reels', 'TikTok 동시', 'Blog 임베드'], status: 'collected' },
{ id: 'a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 리뷰 12,509건', description: '9.5점/10 — 눈·코·가슴 케이스 중심, 티에스의 최대 사회적 증거', repurposingSuggestions: ['50개 선별 → Carousel 시리즈', 'Instagram Stories', '광고 소셜프루프', 'Blog 후기'], status: 'pending' },
{ id: 'a3', source: 'blog', sourceLabel: '연탐 카페', type: 'text', title: '연탐 네이버 카페 후기', description: '카페 시술별 후기 자산 — SNS 연동 미흡', repurposingSuggestions: ['Carousel 재편집', 'Blog 변환', 'Instagram Stories'], status: 'pending' },
{ id: 'a4', source: 'social', sourceLabel: 'Instagram', type: 'photo', title: '@tsprs_official 게시물 1,500개', description: '2,626 팔로워, Reels 60개 누적', repurposingSuggestions: ['고성과 Reel 재배포', 'Carousel 재편집', 'TikTok 크로스'], status: 'collected' },
{ id: 'a5', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '신사역 본원 시설 사진', description: '본원 내외부 고화질', repurposingSuggestions: ['Instagram 시설 Reel', 'YouTube B-roll', '블로그 위치 안내'], status: 'collected' },
{ id: 'a6', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '눈·코·가슴·리프팅 시술 설명', description: '시술별 상세 텍스트', repurposingSuggestions: ['블로그 SEO 소스', 'Carousel 텍스트', '광고 카피'], status: 'collected' },
{ id: 'a7', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '리얼모델 Before/After 갤러리', description: '눈·코·가슴 리얼모델 전후 — 동의서 보유', repurposingSuggestions: ['Instagram B/A 시리즈', 'YouTube 롱폼 소스', '광고 소재'], status: 'collected' },
{ id: 'a8', source: 'homepage', sourceLabel: '홈페이지', type: 'video', title: '브랜드 영상 "지금, 예뻐져라!" (제작 필요)', description: '리얼모델 시스템 소개 브랜드 영상 신규 제작', repurposingSuggestions: ['YouTube 채널 트레일러', 'Instagram 브랜드 Reel', '웹사이트 히어로'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '눈성형 리얼모델 브이로그 #1', views: 0, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'Instagram Reel', '카페 공유', 'Blog 임베드'] },
{ title: '코성형 Before/After 케이스', views: 0, type: 'Long', repurposeAs: ['Shorts 3개', 'Carousel', 'Blog'] },
{ title: '가슴성형 보형물 Q&A', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'TikTok 동시', 'Blog'] },
{ title: '리얼모델 이달의 케이스', views: 0, type: 'Short', repurposeAs: ['Instagram Reel', 'TikTok', '카페'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '715 영상 SEO 최적화 + Shorts 200개 추출', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'high',
priority: 'high',
outputs: [
{ format: 'Shorts 200개 (전체)', channel: 'YouTube / Instagram / TikTok', description: '715 영상 → 리얼모델·시술별 Shorts 추출, 3채널 동시' },
{ format: 'SEO 타이틀 재작성', channel: 'YouTube', description: '"강남 눈코가슴 리얼모델" 패턴 715개 일괄 적용' },
{ format: 'Blog 임베드 포스트 50개', channel: 'Naver Blog', description: '영상 + 텍스트 가이드 구조 블로그 변환' },
],
},
{
sourceVideo: { title: '강남언니 12,509 리뷰 + 연탐 카페 후기 콘텐츠화', views: 12509, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Carousel 20개', channel: 'Instagram', description: '시술별 리얼 후기 카드뉴스 — 익명화 처리' },
{ format: '광고 소재', channel: 'Facebook / Instagram', description: '"9.5점 리얼모델 검증" 소셜프루프 광고' },
{ format: 'Blog 후기 포스트', channel: 'Naver Blog', description: '케이스별 상세 후기 변환' },
],
},
{
sourceVideo: { title: '리얼모델 가슴성형 브이로그 (신규 제작)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'medium',
outputs: [
{ format: 'Shorts 5개', channel: 'YouTube / TikTok', description: '수술 전·중·후 핵심 구간 — "지금, 예뻐져라!" 오프닝' },
{ format: 'Carousel', channel: 'Instagram', description: '가슴성형 리얼모델 Before/After 카드뉴스' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '리얼모델 가슴성형 SEO 후기 포스트' },
{ format: 'Ad Creative', channel: 'Facebook / Instagram', description: '리얼모델 결과 + "지금, 예뻐져라!" CTA 광고' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'wf-001',
title: '리얼모델 눈성형 Shorts — "지금, 예뻐져라!"',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"지금, 예뻐져라!"\n(Before → After 전환)\n\n[본문 — 3~25초]\n"티에스성형외과 리얼모델 눈성형."\n"수술 전과 후, 6개월의 변화를 직접 보여드립니다."\n"강남언니 9.5점 · 12,509 리뷰가 증명합니다."\n\n[CTA — 25~30초]\n"지금 상담 예약 — 카카오톡 @tsprs"`,
shootingGuide: [
'715 영상 중 리얼모델 눈성형 베스트 컷 추출',
'Dark Navy+Crimson 워터마크 하단 고정',
'한글 자막 필수',
'리얼모델 동의서 확인 + 비식별화 옵션',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '강남언니 리얼 후기 5선 Carousel',
contentType: 'image-text',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
userNotes: '연탐 카페 후기도 포함해서 만들어주세요',
imageTextDraft: {
type: 'cardnews',
headline: '지금, 예뻐져라! — 강남언니 12,509 리뷰 중 진심 5개',
copy: [
'[카드 1] "리얼모델 보고 결정했는데, 딱 그대로 됐어요" — 눈성형',
'[카드 2] "티에스가 결과로 먼저 보여줘서 믿었습니다" — 코성형',
'[카드 3] "연탐 카페에서 후기 보고 결정 — 후회 없습니다" — 가슴성형',
'[카드 4] "카카오 상담부터 수술까지 빠르고 친절했어요"',
'[카드 5] 강남언니 9.5점 · 티에스성형외과 — 지금, 예뻐져라!',
],
layoutHint: '5장 세로형, Dark Navy+Crimson, 마지막 카드에 카카오 상담 CTA',
},
},
{
id: 'wf-003',
title: '강남 눈성형 가이드 — SEO 블로그 (리얼모델)',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '강남 눈성형 추천 — 티에스성형외과 리얼모델이 직접 증명합니다',
copy: [
'"지금, 예뻐져라!" — 이 슬로건은 리얼모델의 실제 결과에서 나왔습니다.',
'티에스성형외과는 2010년 신사역에서 시작해 16년간 눈·코·가슴에 집중했습니다.',
'강남언니 9.5점 / 12,509 리뷰, 연탐 네이버 카페 — 환자가 만든 신뢰입니다.',
'티에스TV 715개 영상에서 실제 수술 과정을 직접 확인하세요.',
],
layoutHint: '1200px 썸네일 + 2,000자, 키워드: 강남 눈성형, 티에스성형외과, 리얼모델 성형',
},
},
{
id: 'wf-004',
title: '리얼모델 코성형 롱폼 — 티에스TV',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'approved',
scheduledDate: '2026-04-21',
videoDraft: {
script: `[오프닝]\n"지금, 예뻐져라! 티에스성형외과입니다."\n"오늘은 리얼모델 코성형 — 상담부터 6개월 후까지 공개합니다."\n\n[Phase 1: 상담]\n"어떤 코를 원하는지 먼저 들었습니다."\n\n[Phase 2: 수술 과정]\n(그래픽 + B-roll 삽입)\n\n[Phase 3: 회복]\n"1주 차, 1개월 차, 6개월 차 — 변화를 직접 보여드립니다."\n\n[클로징]\n"강남언니 12,509 리뷰 · 티에스성형외과 — 지금, 예뻐져라!"`,
shootingGuide: [
'리얼모델 동의 확인 + 촬영 일정 조율',
'Dark Navy+Crimson 인트로/아웃트로 6초',
'한글 자막 필수',
'6개월 추적 촬영 일정 사전 확정',
],
duration: '10분',
},
},
],
},
};

View File

@ -0,0 +1,439 @@
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
*
*
* (`mockReport_wonjin.ts` 2026-04-14 ):
* - YouTube @wjwonjin: 14.1K , 350 , 3~5 (Shorts )
* - Instagram: @wonjin_official 23.4K (KR), @wonjin_ps 18K (KR ), @wj_cosmetic ()
* - Facebook @KwonjinPS: 19K (EN, )
* - @popokpop:
* - : 9.3/11,846 ( 5%)
* - 25 ( ), , · ,
* - : ////
* - Primary: Deep Purple (#2C1654), Accent: Violet (#8B5CF6)
*/
export const mockPlanWonjin: MarketingPlan = {
id: 'wonjin',
reportId: 'wonjin',
clinicName: '원진성형외과',
clinicNameEn: 'Wonjin Plastic Surgery',
createdAt: '2026-04-14',
targetUrl: 'https://www.k-wonjin.co.kr',
// ─── Section 1: Brand Guide ───
brandGuide: {
colors: [
{ name: 'Wonjin Deep Purple', hex: '#2C1654', usage: '공식 로고 메인, 헤딩, 강조 텍스트' },
{ name: 'Wonjin Violet', hex: '#8B5CF6', usage: '로고 악센트, CTA 포인트, 강조 요소' },
{ name: 'Pure White', hex: '#FFFFFF', usage: '배경, 카드, 여백' },
{ name: 'Purple Text', hex: '#1E1038', usage: '본문 텍스트' },
{ name: 'Muted Lavender', hex: '#A08CC0', usage: '서브 텍스트, 메타 정보' },
],
fonts: [
{ family: 'Pretendard', weight: 'Bold 700', usage: '헤딩, 섹션 타이틀', sampleText: '25년 강남 코성형 전문 · 국제 환자 1위' },
{ family: 'Pretendard', weight: 'Regular 400', usage: '본문 텍스트', sampleText: '원진성형외과 권진 원장' },
{ family: 'Playfair Display', weight: 'Bold 700', usage: '영문 헤딩', sampleText: 'WONJIN Plastic Surgery' },
],
logoRules: [
{ rule: 'Deep Purple+Violet 공식 로고 통일', description: '원진성형외과 공식 로고를 모든 채널에서 동일 사용', correct: true },
{ rule: '다국어 로고 버전 제작', description: 'KR / EN / CN / JP / TH 5개 언어 로고 버전 제작 — 국제 채널용', correct: false },
{ rule: '3개 Instagram 계정 프로필 통일', description: '@wonjin_official / @wj_cosmetic 계정 프로필 로고 고해상도 1080×1080 통일', correct: false },
{ rule: '채널별 언어 역할 명확화', description: '@wonjin_official KR 메인, @wj_cosmetic 글로벌 전용으로 역할 분리', correct: true },
],
toneOfVoice: {
personality: ['글로벌 전문가', '신뢰감 있는', '25년 역사', '국제적', '환자 중심'],
communicationStyle: '25년 축적된 코성형·눈성형 임상 경험과 강남언니 11,846 리뷰를 근거로, 국내외 환자 모두에게 신뢰를 전달합니다. 다국어 소통으로 글로벌 환자를 환영하는 따뜻한 전문성.',
doExamples: [
'"25년 코성형 전문 — 원진성형외과"',
'"강남언니 11,846 리뷰 · 9.3점이 증명합니다"',
'"한/영/중/일/태 — 어느 언어로도 상담드립니다"',
'"권진 원장의 코성형 원칙: 자연스러움이 기준입니다"',
],
dontExamples: [
'"강남 최고! 파격 할인!"',
'"100% 만족 보장"',
'"연예인 시술 병원"',
],
},
channelBranding: [
{ channel: 'YouTube', icon: 'youtube', profilePhoto: 'Wonjin 원형 로고 1080×1080', bannerSpec: '2560×1440px, Deep Purple 배경 + Violet 악센트, "25년 강남 코성형 전문"', bioTemplate: 'WJ원진성형외과 — 코성형·눈성형 전문\n25년 임상 · 강남역 | 02-544-0404\nk-wonjin.co.kr', currentStatus: 'incorrect' },
{ channel: 'Instagram KR (@wonjin_official)', icon: 'instagram', profilePhoto: 'Wonjin Deep Purple 원형 로고', bannerSpec: 'N/A (하이라이트: 코성형/눈성형/후기/이벤트/글로벌 — Purple 톤)', bioTemplate: '원진성형외과 KR 공식 — 25년 임상\n강남역 | 02-544-0404 | k-wonjin.co.kr', currentStatus: 'incorrect' },
{ channel: 'Instagram Global (@wj_cosmetic)', icon: 'instagram', profilePhoto: 'Wonjin 영문 원형 로고', bannerSpec: 'N/A (Global highlights: nose/eyes/before-after/reviews)', bioTemplate: 'Wonjin Plastic Surgery — 35 Years of Excellence\nGangnam Seoul | k-wonjin.co.kr', currentStatus: 'incorrect' },
{ channel: 'Facebook EN (@KwonjinPS)', icon: 'facebook', profilePhoto: 'Wonjin 공식 로고', bannerSpec: '820×312px, Deep Purple + Violet, "International Patients Welcome"', bioTemplate: 'Wonjin Plastic Surgery — 35 Years · 5 Languages\nGangnam Seoul | k-wonjin.co.kr', currentStatus: 'incorrect' },
{ channel: 'Naver Blog', icon: 'globe', profilePhoto: 'Wonjin 로고', bannerSpec: '블로그 상단: 로고 + 코성형·눈성형 카테고리 메뉴', bioTemplate: '원진성형외과 공식 블로그 — 25년 코성형 전문\n강남역 | k-wonjin.co.kr', currentStatus: 'incorrect' },
],
brandInconsistencies: [
{
field: 'Instagram 3개 계정 역할 혼선',
values: [
{ channel: '@wonjin_official', value: 'KR 공식 (23.4K) — 역할 불명확', isCorrect: false },
{ channel: '@wonjin_ps', value: 'KR 서브 (18K) — 역할 중복', isCorrect: false },
{ channel: '@wj_cosmetic', value: '글로벌 (팔로워 미확인) — 저활용', isCorrect: false },
],
impact: '3개 계정 분산으로 팔로워 합산 대비 실제 도달 낮음. 콘텐츠 파편화로 브랜드 파워 희석',
recommendation: '@wonjin_official KR 메인 집중, @wj_cosmetic 글로벌 전용, @wonjin_ps 통합 또는 휴면 전환',
},
{
field: '다국어 포지셔닝 미반영',
values: [
{ channel: 'YouTube', value: 'WJ원진성형외과 (한국어 위주)', isCorrect: false },
{ channel: 'Facebook EN', value: 'International Patients Welcome (양호)', isCorrect: true },
{ channel: 'Website', value: '다국어 지원 (양호)', isCorrect: true },
{ channel: 'Instagram KR', value: '한국어 전용 바이오 (국제 환자 접근 어려움)', isCorrect: false },
],
impact: '5개 언어 상담 지원이라는 핵심 USP가 Instagram KR·YouTube에 미반영',
recommendation: '전 채널 바이오에 "한/영/중/일/태 상담 가능" 명시 + YouTube 다국어 자막 추가',
},
],
},
// ─── Section 2: Channel Strategies ───
channelStrategies: [
{
channelId: 'youtube', channelName: 'YouTube', icon: 'youtube',
currentStatus: '@wjwonjin — 14.1K 구독자, 350 영상, Shorts 활발 (롱폼 부족)',
targetGoal: '30K 구독자 / 12개월, 롱폼 Before/After 월 4편 추가',
contentTypes: ['롱폼 코성형 케이스 (Before/After)', '외국인 환자 브이로그 (다국어 자막)', '권진 원장 전문의 해설 시리즈'],
postingFrequency: '주 4-5회 (Shorts 3 + 롱폼 1)',
tone: '"25년 코성형 전문의가 말하는" — 국내외 환자 모두 대상',
formatGuidelines: [
'롱폼: 코성형·눈성형 케이스 스터디 — 권진 원장 직접 해설',
'외국인 환자 브이로그: EN/CN/JP/TH 자막 추가',
'썸네일: Deep Purple+Violet 통일, 코 Before/After 병치',
'타이틀: "25년 코성형 전문의가 알려주는 ___" 패턴',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'instagram_kr', channelName: 'Instagram KR (@wonjin_official)', icon: 'instagram',
currentStatus: '@wonjin_official — 23.4K 팔로워, Reels 80개 (양호, 확장 여지)',
targetGoal: '60K 팔로워 / 12개월, Reels 주 5개',
contentTypes: ['Reels (YouTube Shorts 동시)', 'Carousel (11,846 리뷰 스토리화)', 'Before/After 시리즈'],
postingFrequency: '일 1회 + Stories 일 2-3개',
tone: '차분하고 전문적인 KR 네이티브 — 코성형 전문 병원',
formatGuidelines: [
'Reels: YouTube Shorts 동시 배포',
'Carousel: 강남언니 11,846 리뷰 중 50개 선별 → 카루셀',
'Stories: 권진 원장 일상, 시술 비하인드',
'해시태그: #원진성형외과 #강남코성형 #권진원장 #25년임상',
],
priority: 'P0',
customerJourneyStage: 'interest',
},
{
channelId: 'instagram_global', channelName: 'Instagram 글로벌 (@wj_cosmetic)', icon: 'instagram',
currentStatus: '@wj_cosmetic — 글로벌 전용, 현재 저활용',
targetGoal: '글로벌 계정 역할 명확화 + 외국인 환자 유입 채널화',
contentTypes: ['EN/CN/JP/TH 다국어 Reels', 'Patient Journey (외국인 환자 브이로그)', 'Before/After (영문 설명)'],
postingFrequency: '주 3-4회',
tone: 'Professional & welcoming — global medical tourism',
formatGuidelines: [
'KR 원본 → 다국어 캡션 번역 배포 자동화',
'YouTube 외국인 환자 브이로그 → IG 클립',
'WhatsApp/LINE 상담 연결 CTA',
],
priority: 'P1',
customerJourneyStage: 'awareness',
},
{
channelId: 'facebook_en', channelName: 'Facebook EN (@KwonjinPS)', icon: 'facebook',
currentStatus: '@KwonjinPS — 19K, 주 2~4회 업로드, 국제 환자 전용',
targetGoal: '글로벌 리타겟 광고 채널 강화, 의료관광 전환 추적',
contentTypes: ['외국인 환자 후기 시리즈', '리타겟 광고 소재', 'Before/After (동의서 확인)'],
postingFrequency: '주 3회 + 광고 상시',
tone: 'Professional & warm — Korea medical tourism storytelling',
formatGuidelines: [
'Facebook Pixel + WhatsApp 연동 — 해외 상담 자동 응대',
'국가별 리타겟 광고: 태국·일본·중국·동남아',
'다국어 환자 후기 영상 시리즈',
],
priority: 'P1',
customerJourneyStage: 'conversion',
},
{
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
currentStatus: '@popokpop — 업로드 저조, SEO 방치',
targetGoal: '주 2회 재가동, 12개월 내 월 20,000 방문자',
contentTypes: ['코성형 SEO 가이드', '권진 원장 Q&A 텍스트 변환', 'YouTube 영상 임베드'],
postingFrequency: '주 2회',
tone: '"강남 코성형", "25년 원진" 키워드 중심',
formatGuidelines: [
'2,000자 이상 SEO 포스트',
'YouTube 코성형 케이스 영상 임베드',
'핵심 키워드: "강남 코성형", "원진성형외과 후기", "코성형 추천"',
],
priority: 'P0',
customerJourneyStage: 'consideration',
},
{
channelId: 'gangnamunni', channelName: '강남언니', icon: 'star',
currentStatus: '9.3점/10, 11,846 리뷰 — 강남 상위 5%, 리마케팅 미활용',
targetGoal: '리뷰 응답률 80%, 15,000 리뷰 / 12개월',
contentTypes: ['리뷰 응답', '시술 정보 최신화'],
postingFrequency: '리뷰 응답 일 단위',
tone: '진심 어린 의료진 답변',
formatGuidelines: [
'응답률 50%(3개월) → 80%(12개월)',
'11,846 리뷰 중 50개 선별 → SNS 콘텐츠',
'외국인 환자 리뷰 영문/중문 답변 병기',
],
priority: 'P0',
customerJourneyStage: 'loyalty',
},
],
// ─── Section 3: Content Strategy ───
contentStrategy: {
pillars: [
{
title: '25년 코성형 전문성',
description: '2001년 개원 이래 25년 축적된 코성형·눈성형 케이스와 기술',
relatedUSP: '35 Years Rhinoplasty Authority',
exampleTopics: ['권진 원장의 코성형 디자인 철학', '25년 케이스에서 배운 재수술 예방법', '자연스러운 코 라인의 기준'],
color: '#2C1654',
},
{
title: '글로벌 환자 여정',
description: '5개 언어 상담 지원 · 외국인 환자 브이로그 · 국제 의료관광 스토리',
relatedUSP: 'Global Medical Tourism Leader',
exampleTopics: ['태국인 환자의 원진성형외과 여정', '일본어로 상담 — 원진 Q&A', '중국인 환자 Before/After 스토리'],
color: '#8B5CF6',
},
{
title: '11,846 리뷰의 증거',
description: '강남언니 상위 5% — 11,846 리뷰를 환자 목소리로 재구성',
relatedUSP: 'Patient-Verified Excellence',
exampleTopics: ['강남언니 9.3점의 의미 — 코성형 후기', '재수술 없이 자연스러운 결과', '코성형 결정 전 알아야 할 것'],
color: '#A08CC0',
},
{
title: 'Instagram 다계정 전략',
description: 'KR 메인(@wonjin_official) + 글로벌(@wj_cosmetic) 역할 분리 및 연계',
relatedUSP: 'Dual-Track Content Strategy',
exampleTopics: ['KR 코성형 Reels 시리즈', 'Global Before/After (EN/CN/JP/TH)', '국내+해외 환자 동시 타겟팅'],
color: '#FFFFFF',
},
],
typeMatrix: [
{ format: 'YouTube Long-form', channels: ['YouTube'], frequency: '월 4편', purpose: '25년 코성형 전문성 증명, 깊은 신뢰' },
{ format: 'Shorts / Reels', channels: ['YouTube', 'Instagram', 'TikTok'], frequency: '주 5개', purpose: '도달 확대, Shorts 균형 유지' },
{ format: 'Carousel (리뷰 스토리)', channels: ['Instagram KR'], frequency: '주 2회', purpose: '11,846 리뷰 사회적 증거화' },
{ format: 'Blog Post (SEO)', channels: ['Naver Blog'], frequency: '주 2회', purpose: '"강남 코성형" 검색 상위 확보' },
{ format: 'Global Content (EN/CN/JP/TH)', channels: ['Instagram Global', 'Facebook EN'], frequency: '주 3회', purpose: '국제 환자 유입, 다국어 도달' },
{ format: 'Ad Creative (글로벌)', channels: ['Facebook EN', 'Instagram Global'], frequency: '월 6개', purpose: '태국·일본·중국 리타겟 광고' },
],
workflow: [
{ step: 1, name: '계정 역할 재정의', description: '@wonjin_official KR / @wj_cosmetic 글로벌 분리 실행', owner: '마케팅 매니저', duration: '1주 (1회성)' },
{ step: 2, name: '주제 선정', description: 'KR 코성형 키워드 + 글로벌 국가별 관심사 매칭', owner: '마케팅 매니저', duration: '1일' },
{ step: 3, name: '콘텐츠 제작', description: 'KR 원본 제작 + 다국어 번역 배포 파이프라인', owner: 'AI + 편집 + 번역', duration: '2-3일' },
{ step: 4, name: '권진 원장 검수', description: '의료 정확성 + 광고법 체크', owner: '권진 원장', duration: '1일' },
{ step: 5, name: '다국어 마감', description: 'EN/CN/JP/TH 자막·캡션 추가', owner: '번역 팀', duration: '1일' },
{ step: 6, name: '채널별 배포', description: 'KR / 글로벌 채널 동시 배포 + UTM', owner: '마케팅 매니저', duration: '당일' },
],
repurposingSource: '권진 원장 코성형 케이스 스터디 롱폼 (10분)',
repurposingOutputs: [
{ format: 'YouTube Long-form', channel: 'YouTube', description: '원본 + EN/CN 자막 병기' },
{ format: 'Shorts 3-5개', channel: 'YouTube / Instagram / TikTok', description: '핵심 구간 다국어 자막 동시 배포' },
{ format: 'Carousel (KR)', channel: 'Instagram KR', description: '코성형 가이드 카드뉴스' },
{ format: 'Global Reel (EN)', channel: 'Instagram Global', description: '영문 캡션 + 글로벌 해시태그' },
{ format: 'Blog Post', channel: 'Naver Blog', description: 'SEO 포스트 (영상 임베드)' },
{ format: 'Ad Creative (글로벌)', channel: 'Facebook EN', description: '태국·일본 리타겟 광고 소재' },
],
},
// ─── Section 4: Content Calendar ───
calendar: {
weeks: [
{
weekNumber: 1, label: 'Week 1: Instagram 계정 정리 & 글로벌 시작',
entries: [
{ dayOfWeek: 0, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: '@wonjin_official KR 리뉴얼 공지 + 역할 재정의 Reel' },
{ dayOfWeek: 1, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 권진 원장의 25년 코성형 원칙' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 자연스러운 라인 Q&A' },
{ dayOfWeek: 2, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '원진성형외과 블로그 재가동 — 25년 코성형 가이드 1편' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 강남언니 11,846 리뷰 베스트 5' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 태국인 환자 브이로그 (TH 자막)' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(EN): 코성형 의료관광 인콰이어리 (태국/일본 타겟)' },
],
},
{
weekNumber: 2, label: 'Week 2: 코성형 전문성 집중',
entries: [
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '코성형 재수술 피하는 법 — 25년 임상 기준' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 코성형 Before/After — 자연스러운 라인' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 코성형 타입별 비교 — 권진 원장 케이스 스터디' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel (글로벌 @wj_cosmetic): Rhinoplasty Guide EN/CN' },
{ dayOfWeek: 3, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '원진성형외과 코성형 후기 — 강남언니 9.3점의 의미' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 코성형 후 회복 기간 Q&A' },
],
},
{
weekNumber: 3, label: 'Week 3: 눈성형 + 글로벌 환자 콘텐츠',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 눈성형 유형별 비교 — 권진 원장 해설' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 눈성형 전후 — 자연스러운 라인' },
{ dayOfWeek: 1, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '눈성형 Q&A 10가지 — 원진성형외과 전문의 답변' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 일본인 환자 브이로그 (JP 자막)' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 코+눈 동시 수술 가이드' },
{ dayOfWeek: 4, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 재수술 케이스 — 권진 원장 해설' },
],
},
{
weekNumber: 4, label: 'Week 4: 전환 & UTM 추적 강화',
entries: [
{ dayOfWeek: 0, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: '롱폼: 원진성형외과 — 25년, 11,846 리뷰의 이야기' },
{ dayOfWeek: 0, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '첫 코성형 상담 준비 가이드 — 원진성형외과' },
{ dayOfWeek: 1, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Reel: 강남역 원진성형외과 시설 투어' },
{ dayOfWeek: 2, channel: 'YouTube', channelIcon: 'youtube', contentType: 'video', title: 'Shorts: 권진 원장 이달의 코성형 케이스' },
{ dayOfWeek: 3, channel: 'Instagram', channelIcon: 'instagram', contentType: 'social', title: 'Carousel: 다국어 상담 안내 — 한/영/중/일/태' },
{ dayOfWeek: 4, channel: 'Naver Blog', channelIcon: 'globe', contentType: 'blog', title: '원진성형외과 11,846 리뷰 — 강남언니 검증' },
{ dayOfWeek: 4, channel: 'Facebook', channelIcon: 'facebook', contentType: 'ad', title: '광고(글로벌): 월말 코성형 상담 CTA — 5개 언어' },
],
},
],
monthlySummary: [
{ type: 'video', label: '영상 (롱폼+Shorts)', count: 14, color: '#2C1654' },
{ type: 'blog', label: '블로그', count: 8, color: '#8B5CF6' },
{ type: 'social', label: 'Instagram (KR+글로벌)', count: 9, color: '#A08CC0' },
{ type: 'ad', label: '글로벌 광고', count: 2, color: '#FFFFFF' },
],
},
// ─── Section 5: Asset Collection ───
assetCollection: {
assets: [
{ id: 'a1', source: 'youtube', sourceLabel: 'YouTube', type: 'video', title: '350개 영상 아카이브 (Shorts 위주)', description: '주 3~5회 Shorts 업로드 지속 중 — 롱폼 추가 필요', repurposingSuggestions: ['롱폼 → Shorts 5개 추출', 'Instagram KR+글로벌 동시', 'TikTok 다국어 배포'], status: 'collected' },
{ id: 'a2', source: 'social', sourceLabel: '강남언니', type: 'text', title: '강남언니 리뷰 11,846건', description: '9.3점/10 — 강남 상위 5%, 코성형 중심 리뷰 최대 자산', repurposingSuggestions: ['50개 선별 → Carousel 시리즈', 'Instagram Stories', '광고 소셜프루프 (국내+글로벌)', 'Blog 후기'], status: 'pending' },
{ id: 'a3', source: 'social', sourceLabel: 'Instagram KR', type: 'photo', title: '@wonjin_official 게시물 1,200개', description: '23.4K 팔로워, 코성형·눈성형 Reels+B/A', repurposingSuggestions: ['고성과 Reel 글로벌 계정 크로스포스팅', 'Carousel 재편집'], status: 'collected' },
{ id: 'a4', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '권진 원장 + 의료진 10명 프로필', description: '권진 원장 + 전문의 10명 고화질 프로필', repurposingSuggestions: ['원장 인터뷰 섬네일', 'Carousel 의료진 소개', '광고 신뢰도 소재'], status: 'collected' },
{ id: 'a5', source: 'homepage', sourceLabel: '홈페이지', type: 'text', title: '5개 언어 시술 설명 텍스트', description: '한/영/중/일/태 시술별 상세 텍스트 — 글로벌 콘텐츠 소스', repurposingSuggestions: ['글로벌 Instagram 캡션 소스', 'Facebook EN 광고 카피', '다국어 블로그'], status: 'collected' },
{ id: 'a6', source: 'homepage', sourceLabel: '홈페이지', type: 'photo', title: '코성형·눈성형 전후 갤러리', description: '권진 원장 코성형 케이스 — 동의서 보유', repurposingSuggestions: ['Instagram B/A 시리즈 (KR+글로벌)', 'YouTube 케이스 롱폼 소스', '광고 소재'], status: 'collected' },
{ id: 'a7', source: 'social', sourceLabel: 'Instagram Global', type: 'video', title: '외국인 환자 브이로그 자산 (제작 필요)', description: '태국·일본·중국 환자 브이로그 시리즈 — 신규 제작 필요', repurposingSuggestions: ['YouTube 다국어 자막 롱폼', '@wj_cosmetic 글로벌 Reel', 'Facebook EN 시리즈'], status: 'needs_creation' },
],
youtubeRepurpose: [
{ title: '코성형 타입별 비교 케이스', views: 0, type: 'Long', repurposeAs: ['Shorts 5개 추출', 'IG KR+글로벌 동시', 'Blog 임베드'] },
{ title: '눈성형 유형별 비교', views: 0, type: 'Long', repurposeAs: ['Shorts 3개', 'Carousel', 'Blog'] },
{ title: '코+눈 동시 수술 케이스', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'Global Reel', 'Blog'] },
{ title: '재수술 예방 가이드', views: 0, type: 'Long', repurposeAs: ['Shorts 추출', 'KR Carousel', 'Blog'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '코성형 케이스 스터디 롱폼 (신규 제작)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개', channel: 'YouTube / Instagram KR / TikTok', description: '코성형 핵심 구간 — 국내 배포' },
{ format: 'Global Reel (EN/CN)', channel: '@wj_cosmetic', description: '영문·중문 자막 추가 후 글로벌 계정 배포' },
{ format: 'Carousel (KR)', channel: 'Instagram KR', description: '코성형 전후 카드뉴스' },
{ format: 'Blog Post', channel: 'Naver Blog', description: 'SEO 코성형 가이드 포스트' },
],
},
{
sourceVideo: { title: '강남언니 11,846 리뷰 콘텐츠화', views: 11846, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Carousel 20개 (KR)', channel: 'Instagram KR', description: '코성형·눈성형 후기 카드뉴스' },
{ format: '글로벌 광고 소재', channel: 'Facebook EN / Instagram Global', description: '"9.3점 강남 상위 5%" 글로벌 소셜프루프' },
{ format: 'Blog 후기 포스트', channel: 'Naver Blog', description: '케이스별 상세 후기 변환' },
],
},
{
sourceVideo: { title: '외국인 환자 브이로그 — 태국 편 (신규)', views: 0, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'YouTube 롱폼 (TH 자막)', channel: 'YouTube', description: '태국어 자막 → 태국 검색 유입' },
{ format: '@wj_cosmetic Reel', channel: 'Instagram Global', description: '태국어 캡션 + 태국 해시태그' },
{ format: 'Facebook EN 광고', channel: 'Facebook EN', description: '태국 국가 타겟 리타겟 광고' },
],
},
],
// ─── Section 7: Workflow Tracker ───
workflow: {
items: [
{
id: 'wf-001',
title: '코성형 Before/After Shorts — @wonjin_official + @wj_cosmetic 동시',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"코가 달라지면 인상 전체가 달라집니다."\n(전 → 후 전환)\n\n[본문 — 3~25초]\n"원진성형외과, 25년 코성형 전문."\n"권진 원장의 원칙: 자연스러움이 기준입니다."\n"강남언니 9.3점 / 11,846 리뷰가 증명합니다."\n\n[CTA — 25~30초]\n"한/영/중/일/태 상담 가능 — 프로필 링크"`,
shootingGuide: [
'Deep Purple+Violet 워터마크 하단 고정',
'한글 자막 필수 + EN/CN 자막 버전 별도',
'KR 배포: @wonjin_official / 글로벌 배포: @wj_cosmetic',
'환자 동의서 확인 + 비식별화',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '강남언니 리뷰 5선 Carousel — KR 버전',
contentType: 'image-text',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
imageTextDraft: {
type: 'cardnews',
headline: '강남언니 11,846 리뷰 중 — 코성형 진심 5개',
copy: [
'[카드 1] "25년 노하우가 뭔지 수술 결과로 처음 알았습니다" — 코성형 환자',
'[카드 2] "재수술 걱정 없이 결정한 이유 — 원진의 원칙이 달랐습니다"',
'[카드 3] "일본에서 왔는데 일어로 상담해주셔서 너무 편했어요"',
'[카드 4] "권진 원장님이 직접 케이스를 설명해주셨습니다"',
'[카드 5] 강남언니 9.3점 · 원진성형외과 — 프로필 링크에서 확인',
],
layoutHint: '5장 세로형, Deep Purple+Violet, 마지막 카드에 다국어 상담 CTA',
},
},
{
id: 'wf-003',
title: '원진성형외과 블로그 재가동 — 25년 코성형',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'blog',
headline: '원진성형외과 25년 — 코성형 전문의가 직접 씁니다',
copy: [
'2001년 개원, 25년간 강남역에서 코성형·눈성형을 전문으로 해왔습니다.',
'강남언니 11,846 리뷰 / 9.3점 — 환자 여러분이 만든 기록입니다.',
'한/영/중/일/태 5개 언어로 상담 가능한 병원, 세계 각국 환자를 모십니다.',
'매주 2회, 코성형·눈성형 전문 정보를 이 블로그에서 공유합니다.',
],
layoutHint: '1200px 썸네일 + 2,000자, 키워드: 강남 코성형, 원진성형외과, 코성형 추천, 25년',
},
},
{
id: 'wf-004',
title: '권진 원장 코성형 25년 인터뷰 — YouTube 롱폼',
contentType: 'video',
channel: 'YouTube',
channelIcon: 'youtube',
stage: 'approved',
scheduledDate: '2026-04-21',
videoDraft: {
script: `[오프닝]\n"안녕하세요, 원진성형외과 권진입니다."\n"25년간 코성형에만 집중한 이유를 말씀드립니다."\n\n[원칙 1: 자연스러움]\n"코성형의 결과는 티가 나지 않는 것이 최고입니다."\n\n[원칙 2: 재수술 예방]\n"좋은 코성형은 10년 후에도 유지됩니다."\n\n[글로벌 메시지]\n"어떤 언어로도 상담드립니다 — 원진성형외과."\n\n[클로징]\n"11,846 리뷰, 25년 — 결과로 증명합니다."`,
shootingGuide: [
'강남역 원진 원장실 자연광',
'권진 원장 정면 + 3/4 앵글',
'한글 + EN + CN 자막 3가지 버전',
'Deep Purple+Violet 인트로/아웃트로',
],
duration: '8분',
},
},
],
},
};

View File

@ -0,0 +1,38 @@
export type UploadCategory = 'all' | 'image' | 'video' | 'text';
export const categoryConfig: Record<UploadCategory, { label: string }> = {
all: { label: '전체' },
image: { label: 'Image' },
video: { label: 'Video' },
text: { label: 'Text' },
};
export const categoryBadge: Record<'image' | 'video' | 'text', string> = {
image: 'bg-brand-tint-purple text-brand-purple-muted shadow-[2px_3px_6px_rgba(155,138,212,0.12)]',
video: 'bg-brand-rose-bg text-brand-rose shadow-[2px_3px_6px_rgba(212,136,154,0.12)]',
text: 'bg-brand-earth-bg text-brand-earth shadow-[2px_3px_6px_rgba(212,168,114,0.12)]',
};
export const ACCEPT_MAP: Record<string, string> = {
'image/*': '.jpg,.jpeg,.png,.gif,.webp,.svg',
'video/*': '.mp4,.mov,.webm,.avi',
'text/*': '.txt,.md,.doc,.docx,.pdf,.csv,.json',
};
export const ALL_ACCEPT = Object.values(ACCEPT_MAP).join(',');
export function categorize(file: File): 'image' | 'video' | 'text' {
if (file.type.startsWith('image/')) return 'image';
if (file.type.startsWith('video/')) return 'video';
return 'text';
}
export function formatSize(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} B`;
}
export function uid() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

View File

@ -0,0 +1,32 @@
import {
YoutubeFilled,
InstagramFilled,
GlobeFilled,
TiktokFilled,
ShareFilled,
} from '@/shared/icons/FilledIcons';
import type { WorkflowStage } from '@/features/plan/types/plan';
export const STAGES: { key: WorkflowStage; label: string; short: string }[] = [
{ key: 'planning', label: '기획 확정', short: '기획' },
{ key: 'ai-draft', label: 'AI 초안', short: 'AI 초안' },
{ key: 'review', label: '검토/수정', short: '검토' },
{ key: 'approved', label: '승인', short: '승인' },
{ key: 'scheduled', label: '배포 예약', short: '배포' },
];
export const STAGE_COLORS: Record<WorkflowStage, { bg: string; text: string; border: string; dot: string }> = {
planning: { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-200', dot: 'bg-slate-400' },
'ai-draft':{ bg: 'bg-brand-tint-violet', text: 'text-brand-purple-faint', border: 'border-[#C5CBF5]', dot: 'bg-[#7A84D4]' },
review: { bg: 'bg-brand-earth-bg', text: 'text-brand-earth', border: 'border-brand-earth-soft', dot: 'bg-[#D4A872]' },
approved: { bg: 'bg-brand-tint-purple', text: 'text-brand-purple-muted', border: 'border-brand-tint-lavender', dot: 'bg-brand-purple-soft' },
scheduled: { bg: 'bg-brand-tint-purple', text: 'text-brand-purple-muted', border: 'border-brand-tint-lavender', dot: 'bg-brand-purple-vivid' },
};
export const channelIconMap: Record<string, typeof YoutubeFilled> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
globe: GlobeFilled,
video: TiktokFilled,
share: ShareFilled,
};

View File

@ -0,0 +1,182 @@
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router';
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '@/features/plan/types/plan';
import { transformReportToPlan } from '../lib/transformPlan';
import { mockPlan } from '../data/mockPlan';
import { mockPlanBanobagi } from '../data/mockPlan_banobagi';
import { mockPlanGrand } from '../data/mockPlan_grand';
import { mockPlanWonjin } from '../data/mockPlan_wonjin';
import { mockPlanTs } from '../data/mockPlan_ts';
import { mockPlanIrum } from '../data/mockPlan_irum';
import { mockPlanO2O } from '../data/mockPlan_o2o';
import { getReport } from '@/shared/api/generated/reports/reports';
import { getPlan } from '@/shared/api/generated/plans/plans';
// TODO(migration): 'analysis_runs' / 'clinics' / 'content_plans' 테이블 직접 조회는
// 현재 백엔드에 대응 엔드포인트 없음. 우선 reports/plans 엔드포인트만 활용하고,
// clinic 메타 일부 필드는 빈 값으로 둠.
const DEMO_PLANS: Record<string, MarketingPlan> = {
'view-clinic': mockPlan,
'banobagi': mockPlanBanobagi,
'grand': mockPlanGrand,
'wonjin': mockPlanWonjin,
'ts': mockPlanTs,
'irum': mockPlanIrum,
'o2o': mockPlanO2O,
};
interface UseMarketingPlanResult {
data: MarketingPlan | null;
isLoading: boolean;
error: string | null;
}
interface LocationState {
report?: Record<string, unknown>;
metadata?: Record<string, unknown>;
reportId?: string;
clinicId?: string;
}
/**
* content_plans DB row MarketingPlan .
* content_plans AI JSONB .
*/
function buildPlanFromContentPlans(
row: Record<string, unknown>,
clinicName: string,
clinicNameEn: string,
targetUrl: string,
): MarketingPlan {
const channelStrategies = (row.channel_strategies || []) as ChannelStrategyCard[];
const contentStrategy = (row.content_strategy || {}) as ContentStrategyData;
const calendar = (row.calendar || { weeks: [], monthlySummary: [] }) as CalendarData;
return {
id: row.id as string,
reportId: (row.run_id as string) || (row.id as string),
clinicName,
clinicNameEn,
createdAt: (row.created_at as string) || new Date().toISOString(),
targetUrl,
brandGuide: (row.brand_guide as MarketingPlan['brandGuide']) || {
colors: [],
fonts: [],
logoRules: [],
toneOfVoice: {
personality: ['전문적', '친근한', '신뢰할 수 있는'],
communicationStyle: '의료 전문 지식을 쉽고 친근하게 전달',
doExamples: [],
dontExamples: [],
},
channelBranding: [],
brandInconsistencies: [],
},
channelStrategies,
contentStrategy: {
pillars: contentStrategy.pillars || [],
typeMatrix: contentStrategy.typeMatrix || [],
workflow: contentStrategy.workflow || [
{ step: 1, name: '기획', description: 'AI 콘텐츠 주제 선정', owner: 'INFINITH AI', duration: '자동' },
{ 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: contentStrategy.repurposingSource || '1개 롱폼 영상',
repurposingOutputs: contentStrategy.repurposingOutputs || [],
},
calendar,
assetCollection: { assets: [], youtubeRepurpose: [] },
};
}
export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult {
const [data, setData] = useState<MarketingPlan | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const location = useLocation();
useEffect(() => {
if (!id) {
setError('No plan ID provided');
setIsLoading(false);
return;
}
const state = location.state as LocationState | undefined;
async function loadPlan() {
try {
// ─── 개발 / 데모: mock 데이터를 즉시 반환 ───
if (id === 'demo') {
setData(mockPlan);
setIsLoading(false);
return;
}
if (id && id in DEMO_PLANS) {
setData(DEMO_PLANS[id]);
setIsLoading(false);
return;
}
// ─── 소스 1: plan 엔드포인트 시도 (AI 생성 전략) ───
const clinicName = '';
const clinicNameEn = '';
const targetUrl = '';
try {
const planRes = await getPlan(id!);
if (planRes.status === 200 && planRes.data) {
const plan = buildPlanFromContentPlans(
planRes.data as unknown as Record<string, unknown>,
clinicName,
clinicNameEn,
targetUrl,
);
setData(plan);
setIsLoading(false);
return;
}
} catch {
// report 기반 폴백으로 넘어감
}
// ─── 소스 2: 네비게이션 state의 report 데이터 (폴백) ───
if (state?.report && state?.metadata) {
const plan = transformReportToPlan({
id: (state.reportId || id),
url: (state.metadata.url as string) || '',
clinic_name: (state.metadata.clinicName as string) || '',
report: state.report,
created_at: (state.metadata.generatedAt as string) || new Date().toISOString(),
});
setData(plan);
setIsLoading(false);
return;
}
// ─── 소스 3: report를 fetch해서 변환 ───
const reportRes = await getReport(id!);
const reportRow = reportRes.data as unknown as Record<string, unknown>;
const plan = transformReportToPlan({
id: (reportRow.id as string) || id!,
url: (reportRow.url as string) || '',
clinic_name: (reportRow.clinic_name as string) || '',
report: reportRow,
created_at: (reportRow.created_at as string) || new Date().toISOString(),
});
setData(plan);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch marketing plan');
} finally {
setIsLoading(false);
}
}
loadPlan();
}, [id, location.state]);
return { data, isLoading, error };
}

View File

@ -0,0 +1,101 @@
import type { CalendarWeek, CalendarEntry } from '@/features/plan/types/plan';
/**
* ISO .
* 1 = (ISO 8601).
*/
function isoWeekToDate(year: number, week: number, dayOffset: number): Date {
// 1월 4일은 항상 1주차에 포함
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7; // Sun=0을 7로 변환
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - (dayOfWeek - 1) + (week - 1) * 7);
monday.setDate(monday.getDate() + dayOffset);
return monday;
}
function formatICSDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
`T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
);
}
function escapeICS(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
function buildVEvent(
entry: CalendarEntry,
weekNumber: number,
year: number,
uid: string,
): string {
const startDate = isoWeekToDate(year, weekNumber, entry.dayOfWeek);
// 종일 이벤트: DTSTART는 DATE만, DTEND는 다음 날
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 1);
const formatDate = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
};
const lines = [
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${formatICSDate(new Date())}Z`,
`DTSTART;VALUE=DATE:${formatDate(startDate)}`,
`DTEND;VALUE=DATE:${formatDate(endDate)}`,
`SUMMARY:${escapeICS(`[${entry.channel}] ${entry.title}`)}`,
entry.description ? `DESCRIPTION:${escapeICS(entry.description)}` : null,
`CATEGORIES:${escapeICS(entry.contentType.toUpperCase())}`,
`STATUS:${entry.status === 'published' ? 'CONFIRMED' : entry.status === 'approved' ? 'TENTATIVE' : 'NEEDS-ACTION'}`,
'END:VEVENT',
].filter(Boolean) as string[];
return lines.join('\r\n');
}
export function exportCalendarToICS(
weeks: CalendarWeek[],
calendarName = 'INFINITH 콘텐츠 캘린더',
): void {
const year = new Date().getFullYear();
const vEvents = weeks.flatMap((week) =>
week.entries.map((entry, idx) =>
buildVEvent(
entry,
week.weekNumber,
year,
`infinith-${week.weekNumber}-${entry.id ?? idx}@infinith.ai`,
),
),
);
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//INFINITH//Marketing Content Calendar//KO',
`X-WR-CALNAME:${escapeICS(calendarName)}`,
'X-WR-TIMEZONE:Asia/Seoul',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
...vEvents,
'END:VCALENDAR',
].join('\r\n');
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'infinith-content-calendar.ics';
a.click();
URL.revokeObjectURL(url);
}

View File

@ -0,0 +1,416 @@
import type { MarketingPlan, ChannelStrategyCard, ContentPillar, AssetCard, YouTubeRepurposeItem } from '@/features/plan/types/plan';
import type { EnrichmentData } from '@/features/report/lib/transformReport';
import { generateContentPlan } from '@/features/studio/lib/contentDirector';
/**
* Supabase marketing_reports report .
* `report` JSONB AI .
*/
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',
};
// 채널별 톤 매트릭스 (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;
}
/**
* Content Director .
* , , , 4 .
*/
function buildCalendar(
channels: ChannelStrategyCard[],
pillars: ContentPillar[],
services: string[],
enrichment: EnrichmentData | undefined,
clinicName: string,
report?: Record<string, unknown>,
) {
// 재활용 제안용 YouTube 인기 영상 추출
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',
}));
// 토픽 생성용 report 키워드 추출 (Audit C5)
const reportKeywords = report?.keywords as { primary?: { keyword: string; monthlySearches?: number }[] } | undefined;
const keywords = reportKeywords?.primary;
// 전략 기반 토픽용 강남언니 데이터 추출 (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 영상 → 영상 에셋
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 게시물 → 사진 에셋
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',
});
}
}
}
// 네이버 블로그 게시물 → 텍스트 에셋
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 재활용 항목
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 }[] = [];
// 채널별 이름 수집
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: '모든 채널에서 동일한 공식 병원명을 사용하세요',
});
}
// 전화번호 수집
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;
}
/**
* Supabase report row MarketingPlan .
* report ( , , )
* .
*/
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),
};
}

View File

@ -0,0 +1,107 @@
import { useEffect } from 'react';
import { useParams, useLocation } from 'react-router';
import { useMarketingPlan } from '../hooks/useMarketingPlan';
import { ReportNav } from '@/features/report/components/ReportNav';
import { PLAN_SECTIONS } from '@/shared/constants/planSections';
// 플랜 섹션 컴포넌트
import PlanHeader from '@/features/plan/components/PlanHeader';
import BrandingGuide from '@/features/plan/components/BrandingGuide';
import ChannelStrategy from '@/features/plan/components/ChannelStrategy';
import ContentStrategy from '@/features/plan/components/ContentStrategy';
import ContentCalendar from '@/features/plan/components/ContentCalendar';
import AssetCollection from '@/features/plan/components/AssetCollection';
import RepurposingProposal from '@/features/plan/components/RepurposingProposal';
import MyAssetUpload from '@/features/plan/components/MyAssetUpload';
import StrategyAdjustmentSection from '@/features/plan/components/StrategyAdjustmentSection';
import WorkflowTracker from '@/features/plan/components/WorkflowTracker';
import PlanCTA from '@/features/plan/components/PlanCTA';
export default function MarketingPlanPage() {
const { id } = useParams<{ id: string }>();
const location = useLocation();
const clinicId = (location.state as { clinicId?: string } | undefined)?.clinicId || null;
const { data, isLoading, error } = useMarketingPlan(id);
// 해시 기반 스크롤: /plan/:id#section-id → 렌더링 후 해당 섹션으로 스크롤
// sticky Navbar (80px) + ReportNav (~48px) 오프셋으로 섹션 상단 가려짐 방지.
useEffect(() => {
if (isLoading || !location.hash) return;
const sectionId = location.hash.slice(1);
const timer = setTimeout(() => {
const el = document.getElementById(sectionId);
if (!el) return;
const STICKY_OFFSET = 128;
const y = el.getBoundingClientRect().top + window.scrollY - STICKY_OFFSET;
window.scrollTo({ top: y, behavior: 'smooth' });
}, 300);
return () => clearTimeout(timer);
}, [isLoading, location.hash]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
<p className="text-slate-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error || !data) {
return (
<div className="min-h-screen flex items-center justify-center pt-20">
<div className="text-center">
<p className="text-[#7C3A4B] font-medium mb-2"> </p>
<p className="text-slate-500 text-sm">
{error ?? '마케팅 플랜을 찾을 수 없습니다.'}
</p>
</div>
</div>
);
}
return (
<div className="pt-20">
<ReportNav sections={PLAN_SECTIONS} />
<div data-plan-content>
<PlanHeader
clinicName={data.clinicName}
clinicNameEn={data.clinicNameEn}
date={data.createdAt}
targetUrl={data.targetUrl}
/>
<BrandingGuide data={data.brandGuide} />
<ChannelStrategy channels={data.channelStrategies} />
<ContentStrategy data={data.contentStrategy} />
<ContentCalendar data={data.calendar} />
<AssetCollection data={data.assetCollection} />
{data.repurposingProposals && data.repurposingProposals.length > 0 && (
<RepurposingProposal proposals={data.repurposingProposals} />
)}
{data.workflow && (
<WorkflowTracker data={data.workflow} />
)}
<div data-no-print>
<MyAssetUpload />
</div>
<div data-no-print>
<StrategyAdjustmentSection clinicId={clinicId} planId={data.id} />
</div>
<PlanCTA />
</div>
</div>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const MarketingPlanPage = lazy(() => import('./pages/MarketingPlanPage'))
export const planRoutes: RouteObject[] = [
{ path: 'plan/:id', element: <MarketingPlanPage /> },
]

View File

@ -0,0 +1,230 @@
import type { BrandInconsistency } from '@/features/report/types/report';
// ─── 섹션 1: 브랜딩 가이드 ───
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[];
}
// ─── 섹션 2: 채널 커뮤니케이션 전략 ───
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';
}
// ─── 섹션 3: 콘텐츠 마케팅 전략 ───
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[];
}
// ─── 섹션 4: 콘텐츠 캘린더 ───
export type ContentCategory = 'video' | 'blog' | 'social' | 'ad';
export interface CalendarEntry {
dayOfWeek: number;
channel: string;
channelIcon: string;
contentType: ContentCategory;
title: string;
// AI 생성 필드 (선택 — 결정론적 엔진과 하위 호환)
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[];
}
// ─── 섹션 5: 에셋 수집 ───
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[];
}
// ─── 섹션 6: 리퍼포징 제안 ───
export interface RepurposingProposalItem {
sourceVideo: YouTubeRepurposeItem;
outputs: RepurposingOutput[];
estimatedEffort: 'low' | 'medium' | 'high';
priority: 'high' | 'medium' | 'low';
}
// ─── 섹션 7: 워크플로우 트래커 ───
export type WorkflowStage = 'planning' | 'ai-draft' | 'review' | 'approved' | 'scheduled';
export type WorkflowContentType = 'video' | 'image-text';
export interface WorkflowVideoDraft {
script: string;
shootingGuide: string[];
duration: string; // 예: '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[];
}
// ─── 루트 플랜 타입 ───
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;
}

View File

@ -0,0 +1,136 @@
/**
* FAQ Pricing .
*
* (plan 15-E ):
* -
* - / / 4 / / / / Export
*
* :
* - (single-open) `useState<string | null>` ID
* - (`prev === id ? null : id`)
* - motion/react AnimatePresence
*
* DS :
* - lucide Plus/Minus ChevronDown( lucide outlined )
* DS "Filled Icons Only" '아이콘 면적 채움' UI utility(chevron·arrow) .
* CTA lucide ArrowRight .
*/
import { useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { ChevronDown } from 'lucide-react';
import { Button } from '@/shared/ui/button';
interface FaqItem {
id: string;
q: string;
a: string;
}
/**
* FAQ .
*
* · ( `refund`, `quality` )
* . ·· + / .
*/
const items: FaqItem[] = [
{
id: 'contract',
q: '계약은 어떻게 진행하나요?',
a: '상담 문의 → 요건·범위 확정 → 계약서 및 세금계산서 발행 순서로 진행됩니다. 계약 기간은 월·분기·연 단위 중 선택 가능하며, 연 계약 시 월 환산 20% 할인이 적용됩니다.',
},
{
id: 'payment',
q: '결제 방식은 어떻게 되나요?',
a: '온라인 카드 결제는 제공하지 않으며, 세금계산서 기반 계좌 입금으로 진행됩니다. 법인·개인사업자 모두 지원하며, 결제 조건(선불/후불)은 계약서에서 협의합니다.',
},
{
id: 'multi-clinic',
q: '분원이 4개 이상인데 어떻게 하나요?',
a: 'INTELLIGENCE+는 최대 3개 분원까지 통합 대시보드를 제공합니다. 4개 이상이거나 그룹사 단위로 분석이 필요하신 경우 커스텀 플랜 상담을 통해 데이터 구조·리포팅 범위를 맞춤 설계해 드립니다.',
},
{
id: 'medical-law',
q: '의료법 광고 심의와 충돌하지 않나요?',
a: 'INFINITH 리포트는 내부 마케팅 전략 문서이며 환자 대상 광고물이 아닙니다. 리포트 내용을 실제 광고·SNS 게시물로 활용하실 경우 의료광고심의위원회 심의가 별도로 필요하며, 이는 고객사(병원) 및 대행사 책임입니다.',
},
{
id: 'gangnamunni',
q: '강남언니 데이터는 어떻게 수집하나요?',
a: '강남언니 앱 및 웹의 공개 병원 페이지에서 병원명·리뷰 수·평점·대표 시술 등 공개 정보만 수집합니다. 개인 식별 정보(PII)는 일절 수집하지 않으며, 수집 주기와 범위는 서비스 약관에 명시되어 있습니다.',
},
{
id: 'data-export',
q: '리포트 데이터를 내보낼 수 있나요?',
a: '모든 플랜에서 PDF 내보내기를 지원합니다. INTELLIGENCE+는 병원 CI(로고·컬러) 반영 커스텀 템플릿을 제공합니다. JSON·CSV 다운로드는 상담 시 요청하실 수 있습니다.',
},
];
export default function FAQ() {
const [openId, setOpenId] = useState<string | null>(null);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.5 }}
className="w-full"
>
{/* 헤드라인 */}
<div className="text-center mb-10">
<h2 className="text-3xl md:text-4xl font-serif font-bold text-primary-900 mb-3">
</h2>
<p className="text-slate-600 break-keep">
·· .
</p>
</div>
{/* 아코디언 리스트 — 콘텐츠 가독성을 위해 inner max-w-3xl, outer w-full */}
<div className="max-w-3xl mx-auto divide-y divide-slate-200 rounded-2xl bg-white border border-slate-200 shadow-sm overflow-hidden">
{items.map((item) => {
const isOpen = openId === item.id;
return (
<div key={item.id}>
<Button
variant="ghost"
onClick={() => setOpenId((prev) => (prev === item.id ? null : item.id))}
aria-expanded={isOpen}
aria-controls={`faq-panel-${item.id}`}
className="w-full h-auto justify-start items-start gap-4 px-6 py-5 text-left rounded-none hover:bg-slate-50/60 transition-colors"
>
<span className="flex-1 text-base font-semibold text-primary-900 break-keep leading-snug whitespace-normal">
{item.q}
</span>
<ChevronDown
className={`w-5 h-5 text-slate-400 shrink-0 mt-0.5 transition-transform duration-200 ${
isOpen ? 'rotate-180 text-accent' : ''
}`}
/>
</Button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
key="answer"
id={`faq-panel-${item.id}`}
role="region"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeInOut' }}
className="overflow-hidden"
>
<div className="px-6 pb-5 text-sm text-slate-600 leading-relaxed break-keep">
{item.a}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
</motion.div>
);
}

View File

@ -0,0 +1,270 @@
/**
* FeatureComparisonTable 3-Tier .
*
* :
* - Cell 3:
* true CheckFilled (accent)
* false CrossFilled (slate-300)
* string ("월 1회", "고급" )
* - INSIGHT "빈도 · 깊이 · 전략 자산 · 범위"
* - INTELLIGENCE `bg-accent/5` (Tier Cards ring-accent )
* - (min-w-[720px])
*
* strip (scannability) .
*/
import { motion } from 'motion/react';
import { CheckFilled, CrossFilled } from '@/shared/icons/FilledIcons';
type CellValue = true | false | string;
interface FeatureRow {
label: string;
insight: CellValue;
intelligence: CellValue;
intelligencePlus: CellValue;
/** 추가 설명(헬프 텍스트) — 필요 시 행 아래 회색 부연 */
hint?: string;
}
interface FeatureCategory {
name: string;
rows: FeatureRow[];
}
/**
* (plan 15-D , INSIGHT )
*
* Tier Cards bullets "차별화 메시지" .
* bullets .
*/
const categories: FeatureCategory[] = [
{
name: '분석',
rows: [
{
label: '월 리포트 수',
insight: '1회',
intelligence: '4회',
intelligencePlus: '10회',
},
{
label: '전 채널 커버리지',
insight: true,
intelligence: true,
intelligencePlus: true,
hint: '홈페이지 · 강남언니 · YouTube · Instagram · Facebook · 네이버 플레이스 · 블로그',
},
{
label: 'Vision AI (의료진·슬로건·인증 자동 추출)',
insight: false,
intelligence: true,
intelligencePlus: true,
},
{
label: '스크린샷 증거 기반 심층 분석',
insight: '기본',
intelligence: '심층',
intelligencePlus: '심층 + 변화 추적',
},
],
},
{
name: '전략',
rows: [
{
label: '콘텐츠 플랜',
insight: '4주',
intelligence: '8주 + 주간 조정',
intelligencePlus: '12개월 + 월간 리뷰',
},
{
label: 'KPI 대시보드',
insight: '기본',
intelligence: '고급 (3/12개월 목표)',
intelligencePlus: '커스텀',
},
{
label: '브랜드 가이드 + 콘텐츠 필러',
insight: false,
intelligence: '5종',
intelligencePlus: '10종',
},
],
},
{
name: '경쟁',
rows: [
{
label: '경쟁사 추적',
insight: '1개',
intelligence: '3개',
intelligencePlus: '5개',
},
{
label: '변동 알림',
insight: false,
intelligence: '주간',
intelligencePlus: '일간',
},
],
},
{
name: '조직',
rows: [
{
label: '멀티 분원 통합 대시보드',
insight: false,
intelligence: false,
intelligencePlus: '최대 3개',
hint: '4개 이상은 커스텀 플랜 상담',
},
{
label: 'PDF 내보내기',
insight: true,
intelligence: true,
intelligencePlus: '병원 CI 커스텀 템플릿',
},
],
},
{
name: '기타',
rows: [
{
label: '신규 기능 베타 우선 접근',
insight: false,
intelligence: false,
intelligencePlus: true,
},
{
label: '지원',
insight: '이메일',
intelligence: '이메일 + 화상 월 1회',
intelligencePlus: '전담 CSM',
},
],
},
];
/** 셀 값 렌더러 — 3종(true/false/string)을 DS 아이콘 규칙으로 일관되게 표현 */
function Cell({ value, isHighlight }: { value: CellValue; isHighlight?: boolean }) {
if (value === true) {
return (
<div className="flex items-center justify-center">
<CheckFilled size={18} className={isHighlight ? 'text-accent' : 'text-emerald-500'} />
</div>
);
}
if (value === false) {
return (
<div className="flex items-center justify-center">
<CrossFilled size={18} className="text-slate-300" />
</div>
);
}
return (
<div
className={`text-sm text-center leading-snug break-keep ${
isHighlight ? 'text-primary-900 font-semibold' : 'text-slate-700'
}`}
>
{value}
</div>
);
}
export default function FeatureComparisonTable() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.5 }}
className="w-full"
>
{/* 섹션 헤드라인 */}
<div className="text-center mb-10">
<h2 className="text-3xl md:text-4xl font-serif font-bold text-primary-900 mb-3">
</h2>
<p className="text-slate-600 break-keep">
· .
</p>
</div>
{/* 테이블 — 모바일 가로 스크롤 */}
<div className="rounded-3xl bg-white border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<div className="min-w-[720px]">
{/* 상단 헤더 행 */}
<div className="grid grid-cols-[1.4fr_1fr_1fr_1fr] border-b border-slate-200">
<div className="px-6 py-5 text-sm font-semibold text-slate-500"></div>
<div className="px-4 py-5 text-center">
<div className="text-sm font-bold text-primary-900">INSIGHT</div>
<div className="text-xs text-slate-400 mt-0.5">·1 </div>
</div>
<div className="px-4 py-5 text-center bg-accent/5 border-x border-accent/15 relative">
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-accent/50 to-accent" />
<div className="text-sm font-bold text-primary-900">INTELLIGENCE</div>
<div className="text-xs text-accent font-semibold mt-0.5"> </div>
</div>
<div className="px-4 py-5 text-center">
<div className="text-sm font-bold text-primary-900">INTELLIGENCE+</div>
<div className="text-xs text-slate-400 mt-0.5">· </div>
</div>
</div>
{/* 카테고리별 렌더링 */}
{categories.map((cat) => (
<div key={cat.name}>
{/* 카테고리 스트립 */}
<div className="grid grid-cols-[1.4fr_1fr_1fr_1fr] bg-slate-50/70 border-b border-slate-100">
<div className="px-6 py-2.5 text-xs font-bold tracking-wide text-slate-500 uppercase">
{cat.name}
</div>
<div className="bg-transparent" />
<div className="bg-accent/5 border-x border-accent/15" />
<div className="bg-transparent" />
</div>
{/* 각 기능 행 */}
{cat.rows.map((row, idx) => (
<div
key={row.label}
className={`grid grid-cols-[1.4fr_1fr_1fr_1fr] items-center ${
idx !== cat.rows.length - 1 ? 'border-b border-slate-100' : ''
}`}
>
<div className="px-6 py-4">
<div className="text-sm text-primary-900 font-medium break-keep">
{row.label}
</div>
{row.hint && (
<div className="text-xs text-slate-400 mt-1 leading-relaxed break-keep">
{row.hint}
</div>
)}
</div>
<div className="px-4 py-4">
<Cell value={row.insight} />
</div>
<div className="px-4 py-4 bg-accent/5 border-x border-accent/15 h-full flex items-center justify-center">
<Cell value={row.intelligence} isHighlight />
</div>
<div className="px-4 py-4">
<Cell value={row.intelligencePlus} />
</div>
</div>
))}
</div>
))}
</div>
</div>
</div>
{/* 보조 안내 */}
<p className="text-xs text-slate-400 text-center mt-4 break-keep">
, .
</p>
</motion.div>
);
}

View File

@ -0,0 +1,81 @@
/**
* Pricing Tier .
*
* plan 2·5 . Feature Comparison Table .
* Tier . mailto .
*/
export type TierId = 'insight' | 'intelligence' | 'intelligence-plus';
export interface Tier {
id: TierId;
name: string;
tagline: string;
monthlyKRW: number; // 원화 (월 단가 · 계약 기준)
annualMonthlyKRW: number; // 원화 (연 계약 시 월 환산)
annualTotalKRW: number; // 원화 (연 계약 총액)
isPopular?: boolean;
/** 모든 Tier는 계약 기반 영업 — 온라인 결제 없음. 상담 문의 mailto로 통일. */
ctaLabel: string;
bullets: string[];
footnote?: string;
}
export const tiers: Tier[] = [
{
id: 'insight',
name: 'INSIGHT',
tagline: '매월 1번, 병원의 온라인 좌표를 점검하세요',
monthlyKRW: 90_000,
annualMonthlyKRW: 72_000,
annualTotalKRW: 864_000,
ctaLabel: '상담 문의',
bullets: [
'월 1회 분석 리포트',
'전 채널 분석 (홈페이지 · 강남언니 · YouTube · Instagram · Facebook · 네이버 플레이스 · 블로그)',
'4주 콘텐츠 플랜',
'경쟁사 추적 1개',
'PDF 내보내기',
],
footnote: '신규 개업의 · 1인 의원 추천',
},
{
id: 'intelligence',
name: 'INTELLIGENCE',
tagline: '경쟁사가 지금 무엇을 바꾸는지, 월 2번 확인하세요',
monthlyKRW: 290_000,
annualMonthlyKRW: 232_000,
annualTotalKRW: 2_784_000,
isPopular: true,
ctaLabel: '상담 문의',
bullets: [
'월 4회 분석 리포트',
'8주 콘텐츠 캘린더 + 주간 KPI 기반 조정',
'Vision AI (의료진·슬로건·인증 자동 추출)',
'경쟁사 추적 3개 · 주간 변동 알림',
'KPI 대시보드 (3/12개월 목표)',
'브랜드 가이드 + 콘텐츠 필러 5종',
'스크린샷 증거 기반 심층 리포트',
],
footnote: '중형 성형외과 · 메인 타겟',
},
{
id: 'intelligence-plus',
name: 'INTELLIGENCE+',
tagline: '매일 변하는 시장에 즉시 대응하세요',
monthlyKRW: 990_000,
annualMonthlyKRW: 792_000,
annualTotalKRW: 9_504_000,
ctaLabel: '상담 문의',
bullets: [
'월 10회 분석 리포트',
'12개월 로드맵 + 월간 전략 리뷰',
'최대 3개 분원 통합 대시보드',
'경쟁사 추적 5개 · 일간 변동 모니터링',
'브랜드 가이드 + 콘텐츠 필러 10종',
'커스텀 리포트 템플릿 (병원 CI 반영)',
'신규 기능 베타 우선 접근',
],
footnote: '대형 · 멀티 분원 병원',
},
];

View File

@ -0,0 +1,388 @@
/**
* PricingPage INFINITH Product 1.0 .
*
* (plan 15-A):
* 1. Hero ( + )
* 2. Billing Toggle ( / 20% )
* 3. 3 Tier Cards (INSIGHT / INTELLIGENCE / INTELLIGENCE+)
* 4. Feature Comparison Table Step 3
* 5. Free Trial
* 6. Launch Promotion
* 7. FAQ Step 3
* 8. Enterprise Contact CTA
*
* :
* - `?from=header | footer | cta | hero` console.log
* (analytics Supabase `analytics_events` )
*
* :
* - src/data/pricingTiers.ts ( , Step 3 )
*/
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import {
CheckFilled,
RocketFilled,
BoltFilled,
PrismFilled,
} from '@/shared/icons/FilledIcons';
import Badge from '@/features/landing/components/Badge';
import FeatureComparisonTable from '../components/FeatureComparisonTable';
import FAQ from '../components/FAQ';
import { buildContactMailto } from '@/shared/lib/contact';
import { Button } from '@/shared/ui/button';
import { tiers, type Tier } from '../data/pricingTiers';
// ─── 가격 포맷터 ──────────────────────────────────────────────────
// 29_0000원 → "29만원" 포맷. 1000원 단위까지는 표기 안 함 (B2B 가격은 만원 단위)
function formatKRW(amount: number): string {
const man = amount / 10_000;
// 소수점 없는 정수 표기 우선. 999,999 아래면 만원, 이상이면 억 단위까지 확장 가능.
if (Number.isInteger(man)) return `${man.toLocaleString('ko-KR')}만원`;
return `${man.toLocaleString('ko-KR', { maximumFractionDigits: 1 })}만원`;
}
// ─── Billing Toggle 컴포넌트 ──────────────────────────────────────
interface BillingToggleProps {
value: 'monthly' | 'annual';
onChange: (v: 'monthly' | 'annual') => void;
}
const BillingToggle: React.FC<BillingToggleProps> = ({ value, onChange }) => {
return (
<div className="inline-flex items-center gap-1 p-1 rounded-full bg-white/70 border border-slate-200 backdrop-blur-sm shadow-sm">
<Button
variant="ghost"
onClick={() => onChange('monthly')}
className={`px-5 py-2 h-auto rounded-full text-sm font-semibold transition-all ${
value === 'monthly'
? 'bg-primary-900 text-white shadow hover:bg-primary-900 hover:text-white'
: 'text-slate-600 hover:text-primary-900'
}`}
>
</Button>
<Button
variant="ghost"
onClick={() => onChange('annual')}
className={`px-5 py-2 h-auto rounded-full text-sm font-semibold transition-all flex items-center gap-2 ${
value === 'annual'
? 'bg-primary-900 text-white shadow hover:bg-primary-900 hover:text-white'
: 'text-slate-600 hover:text-primary-900'
}`}
>
<span
className={`text-xs px-2 py-0.5 rounded-full font-bold ${
value === 'annual'
? 'bg-white/20 text-white'
: 'bg-accent/10 text-accent'
}`}
>
20%
</span>
</Button>
</div>
);
};
// ─── Tier Card 컴포넌트 ──────────────────────────────────────────
interface TierCardProps {
tier: Tier;
billing: 'monthly' | 'annual';
onSelect: (tier: Tier) => void;
}
const TierCard: React.FC<TierCardProps> = ({ tier, billing, onSelect }) => {
const price = billing === 'monthly' ? tier.monthlyKRW : tier.annualMonthlyKRW;
const isAnnual = billing === 'annual';
return (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className={`relative flex flex-col rounded-3xl p-8 bg-white border shadow-sm transition-all hover:shadow-xl ${
tier.isPopular
? 'border-accent/40 ring-2 ring-accent/30 shadow-lg scale-[1.02]'
: 'border-slate-200'
}`}
>
{/* Popular 배지 */}
{tier.isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge variant="popular" size="md" />
</div>
)}
{/* 이름 + 태그라인 */}
<div className="mb-6">
<h3 className="text-2xl font-serif font-bold text-primary-900 mb-2">{tier.name}</h3>
<p className="text-sm text-slate-500 leading-relaxed break-keep">{tier.tagline}</p>
</div>
{/* 가격 */}
<div className="mb-6">
<div className="flex items-baseline gap-2">
<span className="text-4xl font-bold text-primary-900 tracking-tight">
{formatKRW(price)}
</span>
<span className="text-slate-500 text-sm">/</span>
</div>
{isAnnual && (
<p className="text-xs text-accent font-semibold mt-2">
{formatKRW(tier.annualTotalKRW)} · 20%
</p>
)}
{!isAnnual && (
<p className="text-xs text-slate-400 mt-2">
{formatKRW(tier.annualMonthlyKRW)}
</p>
)}
</div>
{/* CTA — DS Primary: gradient + rounded-full (pill) */}
<Button
onClick={() => onSelect(tier)}
className={`w-full h-auto px-8 py-3.5 rounded-full font-medium text-sm text-white transition-shadow duration-150 shadow-md hover:shadow-xl flex items-center justify-center gap-2 group bg-gradient-to-r from-brand-purple to-brand-purple-deep hover:from-[#AF90FF] hover:to-[#AF90FF] active:scale-[0.98] mb-6 ${
tier.isPopular ? 'shadow-lg' : ''
}`}
>
{tier.ctaLabel}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Button>
{/* Bullet points — DS FilledIcon(CheckFilled) */}
<ul className="space-y-3 flex-grow">
{tier.bullets.map((bullet, i) => (
<li key={i} className="flex items-start gap-2.5 text-sm text-slate-700">
<CheckFilled size={16} className="text-accent shrink-0 mt-0.5" />
<span className="leading-relaxed break-keep">{bullet}</span>
</li>
))}
</ul>
{/* Footnote */}
{tier.footnote && (
<p className="mt-6 pt-4 border-t border-slate-100 text-xs text-slate-400 text-center">
{tier.footnote}
</p>
)}
</motion.div>
);
};
// ─── 먼저 문의하기 강조 카드 ─────────────────────────────────────
// DS 레퍼런스: CTA 카드 배경에 3-stop warm gradient (Section 2.2)
// + 상단 filled 아이콘 squircle + Primary/Secondary pill 버튼 병렬
//
// PART III 피봇: "첫 리포트 무료 / 카드 등록 불필요" 표현 삭제 → 계약 기반 영업으로 일원화.
function ContactFirstBox() {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="relative w-full rounded-3xl p-8 md:p-12 text-center overflow-hidden bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky border border-white/40 shadow-[3px_4px_12px_rgba(0,0,0,0.06)]"
>
{/* 상단 아이콘 squircle — DS filled icon 규칙 */}
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-white/60 backdrop-blur-sm border border-white/50 mb-6">
<BoltFilled size={26} className="text-accent" />
</div>
<h3 className="text-2xl md:text-3xl font-serif font-bold text-primary-900 mb-3">
</h3>
<p className="text-slate-600 leading-relaxed max-w-xl mx-auto mb-8 break-keep">
· ,
.
</p>
{/* DS 듀얼 버튼: Primary + Secondary — 모두 rounded-full (pill) */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<Button
asChild
className="h-auto px-8 py-3.5 rounded-full font-medium text-sm text-white transition-shadow duration-150 shadow-md hover:shadow-xl group bg-gradient-to-r from-brand-purple to-brand-purple-deep hover:from-[#AF90FF] hover:to-[#AF90FF] active:scale-[0.98]"
>
<a href={buildContactMailto('도입 상담 문의')}>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Button>
<Button
asChild
variant="outline"
className="h-auto px-8 py-3.5 rounded-full font-medium text-sm bg-white border-slate-200 text-brand-purple-deep hover:bg-slate-50"
>
<a href="#tiers">
</a>
</Button>
</div>
</motion.div>
);
}
// ─── Launch Promotion 배너 ───────────────────────────────────────
// DS: 🎁 이모지 금지(Principle: No Emoji) → RocketFilled 대체
// dark primary gradient card + Secondary pill 버튼
function PromotionBanner() {
return (
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
className="w-full rounded-3xl bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white p-6 md:p-8 flex flex-col md:flex-row items-center gap-4 md:gap-6"
>
{/* Filled icon squircle — 🎁 대체 */}
<div className="inline-flex items-center justify-center w-12 h-12 rounded-2xl bg-white/10 backdrop-blur-sm shrink-0">
<RocketFilled size={24} className="text-white" />
</div>
<div className="flex-1 text-center md:text-left">
<p className="text-xs font-semibold text-purple-200 mb-1 tracking-wide">
· 20
</p>
<p className="text-base md:text-lg font-serif">
INTELLIGENCE · INTELLIGENCE+ <strong>3 30% </strong>
</p>
</div>
<Button
asChild
variant="outline"
className="h-auto px-6 py-2.5 rounded-full bg-white border-slate-200 text-brand-purple-deep font-medium text-sm hover:bg-slate-50 whitespace-nowrap"
>
<a href={buildContactMailto('런칭 프로모션 문의')}>
</a>
</Button>
</motion.div>
);
}
// ─── Enterprise Contact CTA ──────────────────────────────────────
// DS: outlined 패턴은 DS에 없음 → Primary pill(gradient + rounded-full)
function EnterpriseContact() {
return (
// outer: w-full로 섹션 정렬 / inner 콘텐츠만 max-w-2xl로 가독성 유지
<div className="w-full text-center">
<div className="max-w-2xl mx-auto">
<h3 className="text-2xl font-serif font-bold text-primary-900 mb-3">
, ?
</h3>
<p className="text-slate-600 mb-8 break-keep">
4 , .
</p>
<Button
asChild
className="h-auto px-8 py-3.5 rounded-full font-medium text-sm text-white transition-shadow duration-150 shadow-md hover:shadow-xl group bg-gradient-to-r from-brand-purple to-brand-purple-deep hover:from-[#AF90FF] hover:to-[#AF90FF] active:scale-[0.98]"
>
<a href={buildContactMailto('커스텀 플랜 문의')}>
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</a>
</Button>
</div>
</div>
);
}
// ─── PricingPage 본체 ────────────────────────────────────────────
export default function PricingPage() {
const [searchParams] = useSearchParams();
const [billing, setBilling] = useState<'monthly' | 'annual'>('monthly');
// 유입 소스 추적 — 추후 Supabase `analytics_events` 테이블로 전송
useEffect(() => {
const from = searchParams.get('from');
if (from) {
// TODO(analytics): Supabase analytics_events insert
console.info(`[pricing] referred from: ${from}`);
}
}, [searchParams]);
/**
* Tier .
* Tier mailto .
* Subject Tier .
*/
const handleTierSelect = (tier: Tier) => {
window.location.href = buildContactMailto(`${tier.name} 플랜 상담 문의`);
};
return (
<main className="relative pt-28 md:pt-32 pb-24 overflow-hidden">
{/* Background — 랜딩 Hero와 톤 통일 */}
<div className="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-indigo-100 via-purple-50 to-pink-50 opacity-60" />
{/* ── Section 1 · Hero ─────────────────────────── */}
<section className="px-6 text-center max-w-4xl mx-auto mb-12">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/70 border border-white/40 text-xs font-bold text-accent mb-6">
<PrismFilled size={14} className="text-accent" />
Pricing ·
</div>
<h1 className="text-4xl md:text-6xl font-serif font-bold text-primary-900 leading-[1.1] tracking-[-0.02em] mb-5">
Strategic Planning,<br className="hidden md:block" />
<span className="text-gradient">At Your Scale.</span>
</h1>
<p className="text-lg text-slate-600 max-w-2xl mx-auto mb-10 break-keep">
. ·· .
</p>
</motion.div>
{/* ── Section 2 · Billing Toggle ────────────── */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<BillingToggle value={billing} onChange={setBilling} />
</motion.div>
</section>
{/* ── Section 3 · 3 Tier Cards ─────────────────── */}
<section id="tiers" className="px-6 max-w-7xl mx-auto mb-20 scroll-mt-24">
<div className="grid md:grid-cols-3 gap-6 md:gap-8 items-stretch">
{tiers.map((tier) => (
<TierCard key={tier.id} tier={tier} billing={billing} onSelect={handleTierSelect} />
))}
</div>
</section>
{/* ── Section 4 · Feature Comparison Table ─── */}
<section className="px-6 mb-20 max-w-7xl mx-auto">
<FeatureComparisonTable />
</section>
{/* ── Section 5 · 먼저 문의하기 강조 ───────────── */}
{/* outer max-w-7xl로 Tier Cards 섹션과 동일 정렬 (반응형 자동 축소) */}
<section className="px-6 mb-16 max-w-7xl mx-auto">
<ContactFirstBox />
</section>
{/* ── Section 6 · Launch Promotion ───────────── */}
<section className="px-6 mb-20 max-w-7xl mx-auto">
<PromotionBanner />
</section>
{/* ── Section 7 · FAQ ─────────────────────────── */}
<section className="px-6 mb-20 max-w-7xl mx-auto">
<FAQ />
</section>
{/* ── Section 8 · Enterprise Contact ─────────── */}
<section className="px-6 max-w-7xl mx-auto">
<EnterpriseContact />
</section>
</main>
);
}

View File

@ -0,0 +1,8 @@
import { lazy } from 'react'
import type { RouteObject } from 'react-router'
const PricingPage = lazy(() => import('./pages/PricingPage'))
export const pricingRoutes: RouteObject[] = [
{ path: 'pricing', element: <PricingPage /> },
]

View File

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

View File

@ -0,0 +1,184 @@
import { motion } from 'motion/react';
import { Calendar, Users, MapPin, Phone, Award, Star, Globe, ExternalLink, Building2 } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import type { ClinicSnapshot as ClinicSnapshotType } from '@/features/report/types/report';
interface ClinicSnapshotProps {
data: ClinicSnapshotType;
}
function formatNumber(n: number): string {
return n.toLocaleString();
}
interface InfoField {
label: string;
value: string;
icon: typeof Calendar;
href?: string;
}
const infoFields = (data: ClinicSnapshotType): InfoField[] => [
data.established ? { label: '개원', value: `${data.established} (${data.yearsInBusiness}년)`, icon: Calendar } : null,
data.staffCount > 0 ? { label: '전문의', value: `${data.staffCount}`, icon: Users } : null,
data.overallRating > 0 ? { label: '강남언니 평점', value: data.overallRating > 5 ? `${data.overallRating} / 10` : `${data.overallRating} / 5.0`, icon: Star } : null,
data.totalReviews > 0 ? { label: '리뷰 수', value: formatNumber(data.totalReviews), icon: Star } : null,
data.registryData?.district ? { label: '지역구', value: data.registryData.district, icon: Building2 } : null,
data.location ? { label: '위치', value: data.nearestStation ? `${data.location} (${data.nearestStation})` : data.location, icon: MapPin } : null,
data.phone ? { label: '전화', value: data.phone, icon: Phone, href: `tel:${data.phone.replace(/[^+0-9]/g, '')}` } : null,
data.domain ? { label: '도메인', value: data.domain, icon: Globe, href: `https://${data.domain.replace(/^https?:\/\//, '')}` } : null,
data.registryData?.websiteEn ? { label: '영문 사이트', value: data.registryData.websiteEn, icon: Globe, href: data.registryData.websiteEn } : null,
].filter(Boolean) as InfoField[];
export default function ClinicSnapshot({ data }: ClinicSnapshotProps) {
const fields = infoFields(data);
const isVerified = data.source === 'registry';
return (
<SectionWrapper id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
{/* 외부 검증 배지는 내부 관리용이므로 리포트에서 제외 */}
{/* Registry 외부 링크 (강남언니, 네이버 플레이스, 구글 맵) */}
{isVerified && (data.registryData?.naverPlaceUrl || data.registryData?.gangnamUnniUrl || data.registryData?.googleMapsUrl) && (
<motion.div
className="flex flex-wrap gap-2 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
{data.registryData?.gangnamUnniUrl && (
<a
href={data.registryData.gangnamUnniUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-pink-50 border border-pink-200 px-3 py-1.5 text-xs font-medium text-pink-700 hover:bg-pink-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.naverPlaceUrl && (
<a
href={data.registryData.naverPlaceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-green-50 border border-green-200 px-3 py-1.5 text-xs font-medium text-green-700 hover:bg-green-100 transition-colors"
>
<ExternalLink size={11} />
</a>
)}
{data.registryData?.googleMapsUrl && (
<a
href={data.registryData.googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-50 border border-blue-200 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
Google Maps <ExternalLink size={11} />
</a>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
{fields.map((field, i) => {
const Icon = field.icon;
return (
<motion.div
key={field.label}
className="rounded-2xl bg-white border border-slate-100 shadow-sm p-5"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.05 }}
>
<div className="flex items-start gap-3">
<div className="shrink-0 w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
<Icon size={16} className="text-slate-400" />
</div>
<div className="min-w-0">
<p className="text-xs text-slate-500 uppercase tracking-wide">{field.label}</p>
{field.href ? (
<a
href={field.href}
target={field.href.startsWith('tel:') ? undefined : '_blank'}
rel="noopener noreferrer"
className="text-lg font-semibold text-[#0A1128] mt-1 hover:text-[#6C5CE7] inline-flex items-center gap-1.5"
>
{field.value}
{!field.href.startsWith('tel:') && <ExternalLink size={14} className="text-[#6C5CE7] shrink-0" />}
</a>
) : (
<p className="text-lg font-semibold text-[#0A1128] mt-1">{field.value}</p>
)}
</div>
</div>
</motion.div>
);
})}
</div>
{/* Lead Doctor Highlight — only show if doctor name exists */}
{data.leadDoctor.name && (
<motion.div
className="rounded-2xl bg-gradient-to-r from-[#4F1DA1]/5 to-[#021341]/5 border border-purple-100 p-6 mb-8"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<div className="flex items-center gap-2 mb-3">
<Award size={20} className="text-[#6C5CE7]" />
<h3 className="font-serif font-bold text-2xl text-[#0A1128]"> </h3>
</div>
<p className="text-xl font-bold text-[#0A1128] mb-1">{data.leadDoctor.name}</p>
{data.leadDoctor.credentials && (
<p className="text-sm text-slate-600 mb-3">{data.leadDoctor.credentials}</p>
)}
{data.leadDoctor.rating > 0 && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={16}
className={i < Math.round(data.leadDoctor.rating) ? 'text-[#6B2D8B] fill-[#6B2D8B]' : 'text-slate-200'}
/>
))}
<span className="text-sm font-semibold text-[#0A1128] ml-1">
{data.leadDoctor.rating}
</span>
</div>
{data.leadDoctor.reviewCount > 0 && (
<span className="text-sm text-slate-500">
{formatNumber(data.leadDoctor.reviewCount)}
</span>
)}
</div>
)}
</motion.div>
)}
{/* Certifications */}
{data.certifications.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<p className="text-sm font-semibold text-slate-700 mb-3"> </p>
<div className="flex flex-wrap gap-2">
{data.certifications.map((cert) => (
<span
key={cert}
className="rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700"
>
{cert}
</span>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

@ -0,0 +1,367 @@
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 { Button } from '@/shared/ui/button';
import type {
FacebookAudit as FacebookAuditType,
FacebookPage,
BrandInconsistency,
DiagnosisItem,
} from '@/features/report/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-brand-tint-violet text-brand-purple-faint';
const isLogoMismatch = page.logo?.includes('불일치');
const isLowFollowers = page.followers < 500;
return (
<motion.div
className={`rounded-2xl border shadow-sm p-6 ${
isKR && isLowFollowers
? 'bg-brand-rose-bg/30 border-brand-rose-soft/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-brand-rose-bg text-brand-rose">
</span>
)}
{page.hasWhatsApp && (
<span className="text-xs font-medium px-3 py-1 rounded-full bg-brand-tint-purple text-brand-purple-muted">
WhatsApp
</span>
)}
</div>
<h3 className="font-bold text-lg text-brand-navy 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-brand-purple-vivid inline-flex items-center gap-1"
>
{page.pageName}
<ExternalLink size={13} className="text-brand-purple-vivid" />
</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-brand-rose' : 'text-brand-navy'}`}>
{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-brand-rose' : 'text-brand-navy'}`}>
{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-brand-navy">{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-brand-navy">{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-brand-navy">{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-brand-navy 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-brand-rose' : 'text-brand-navy'
}`}>{page.engagement}</span>
</div>
)}
</div>
{/* Logo analysis - the enhanced version */}
<div className={`rounded-xl p-4 mb-4 ${
isLogoMismatch
? 'bg-brand-rose-bg border border-brand-rose-soft'
: 'bg-brand-tint-purple border border-brand-tint-lavender'
}`}>
<div className="flex items-start gap-2 mb-2">
{isLogoMismatch
? <AlertTriangle size={16} className="text-brand-rose shrink-0 mt-1" />
: <CheckCircle2 size={16} className="text-brand-purple-soft shrink-0 mt-1" />
}
<p className={`text-sm font-semibold ${isLogoMismatch ? 'text-brand-rose' : 'text-brand-purple-muted'}`}>
{page.logo}
</p>
</div>
<p className={`text-xs leading-relaxed ml-6 ${isLogoMismatch ? 'text-brand-rose' : 'text-brand-purple-muted'}`}>
{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-brand-earth' : '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-brand-navy 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
type="button"
variant="ghost"
onClick={() => setExpanded(expanded === i ? null : i)}
className="w-full flex items-center justify-between p-5 h-auto text-left hover:bg-slate-50/50 rounded-none"
>
<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-brand-navy">{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-brand-tint-purple/60' : 'bg-brand-rose-bg/60'
}`}
>
<span className="font-medium text-slate-700 min-w-[100px]">{v.channel}</span>
<span className={`flex-1 text-right ${v.isCorrect ? 'text-brand-purple-muted' : 'text-brand-rose'}`}>
{v.value}
</span>
<span className="ml-3">
{v.isCorrect
? <CheckCircle2 size={15} className="text-brand-purple-soft" />
: <XCircle size={15} className="text-brand-rose-mid" />
}
</span>
</div>
))}
</div>
{/* Impact */}
<div className="rounded-xl bg-brand-earth-bg border border-brand-earth-soft p-4 mb-3">
<p className="text-xs font-semibold text-brand-earth uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
Impact
</p>
<p className="text-sm text-brand-earth">{item.impact}</p>
</div>
{/* Recommendation */}
<div className="rounded-xl bg-brand-tint-purple border border-brand-tint-lavender p-4">
<p className="text-xs font-semibold text-brand-purple-muted uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
Recommendation
</p>
<p className="text-sm text-brand-purple-muted">{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-brand-navy 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-brand-navy">{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-brand-purple to-brand-purple-deep p-6 md:p-8 text-white"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.5 }}
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0">
<Globe size={20} className="text-white" />
</div>
<div>
<h4 className="font-serif font-bold text-2xl mb-2"> </h4>
<p className="text-sm text-purple-200 leading-relaxed">{text}</p>
</div>
</div>
</motion.div>
);
}
/* ─── Main Component ─── */
export default function FacebookAudit({ data }: { data: FacebookAuditType }) {
const hasData = data.pages.length > 0 || data.diagnosis.length > 0;
if (!hasData) return null;
return (
<SectionWrapper id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
{/* Page cards side by side */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{data.pages.map((page, i) => (
<PageCard key={page.pageName} page={page} index={i} />
))}
</div>
{/* Brand Consistency Map - the new enhanced section */}
{data.brandInconsistencies && data.brandInconsistencies.length > 0 && (
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} />
)}
{/* Diagnosis table */}
{data.diagnosis && data.diagnosis.length > 0 && (
<DiagnosisSection items={data.diagnosis} />
)}
{/* Consolidation recommendation */}
{data.consolidationRecommendation && (
<ConsolidationCard text={data.consolidationRecommendation} />
)}
</SectionWrapper>
);
}

View File

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

View File

@ -0,0 +1,132 @@
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 { Button } from '@/shared/ui/button';
import { useExportPDF } from '@/features/report/hooks/useExportPDF';
import type { KPIMetric } from '@/features/report/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('측정 불가');
}
/** 가독성을 위해 큰 숫자 포맷팅: 150000 → 150K, 1500000 → 1.5M */
function formatKpiValue(value: string | number): string {
const str = String(value ?? '');
// 이미 포맷된 경우(K, M, %, ~, 월, 건 등 포함) 그대로 반환
if (/[KkMm%~월건회개명/]/.test(str)) return str;
// 순수 숫자로 파싱 시도
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-brand-navy 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-brand-navy">{metric.metric}</div>
<div
className={`px-6 py-4 text-sm font-semibold ${
isNegativeValue(metric.current) ? 'text-brand-rose' : 'text-brand-navy'
}`}
>
{formatKpiValue(metric.current)}
</div>
<div className="px-6 py-4 text-sm font-medium text-brand-purple-muted">{formatKpiValue(metric.target3Month)}</div>
<div className="px-6 py-4 text-sm font-medium text-brand-purple-muted">{formatKpiValue(metric.target12Month)}</div>
</motion.div>
))}
</motion.div>
{/* CTA Card */}
<motion.div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky 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-brand-purple-deep/10 flex items-center justify-center mx-auto mb-6">
<TrendingUp size={28} className="text-brand-purple-deep" />
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-brand-purple-deep mb-3">
Start Your Transformation
</h3>
<p className="text-brand-purple-deep/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
type="button"
onClick={() => navigate(`/plan/${id || 'live'}#branding-guide`)}
className="inline-flex items-center gap-2 bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white font-semibold px-8 py-4 h-auto rounded-full hover:from-brand-purple hover:to-brand-purple-deep hover:shadow-xl"
>
<ArrowUpRight size={18} />
</Button>
<Button
type="button"
variant="outline"
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
disabled={isExporting}
className="inline-flex items-center gap-2 bg-white border-slate-200 text-brand-purple-deep font-semibold px-8 py-4 h-auto rounded-full hover:bg-slate-50 shadow-sm hover:shadow-md disabled:opacity-60 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
<>
<Download size={18} />
</>
)}
</Button>
</div>
</div>
</motion.div>
</SectionWrapper>
);
}

View File

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

View File

@ -0,0 +1,179 @@
import { motion } from 'motion/react';
import { SectionWrapper } from './ui/SectionWrapper';
import { ShieldFilled, FileTextFilled, LinkExternalFilled } from '@/shared/icons/FilledIcons';
import type { DiagnosisItem } from '@/features/report/types/report';
interface ProblemDiagnosisProps {
diagnosis: DiagnosisItem[];
}
/**
* 3 .
* AI ,
* .
*/
function clusterDiagnosis(items: DiagnosisItem[]): {
icon: typeof ShieldFilled;
title: string;
detail: string;
}[] {
// 3개의 전략 버킷으로 항목 분류
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();
// 브랜드 아이덴티티 / 일관성 이슈
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);
}
// 콘텐츠 / 전략 이슈
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);
}
// 퍼널 / 크로스 플랫폼 / 전환 이슈
else {
funnelItems.push(item.detail);
}
}
// 분배가 잘 안 됐을 경우 균형 맞추기
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;
// 항목이 3개일 때 첫 카드는 md 화면에서 전체 너비 사용
const isWide = i === 2;
return (
<motion.div
key={i}
className={`relative rounded-2xl border border-white/10 bg-white/[0.06] backdrop-blur-md p-7 overflow-hidden ${
isWide ? 'md:col-span-2' : ''
}`}
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.12 }}
>
{/* Glow accent */}
<div className="absolute -top-6 -right-6 w-24 h-24 bg-[#C084CF]/20 rounded-full blur-2xl pointer-events-none" />
<div className="flex items-start gap-4">
<div className="shrink-0 w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center">
<Icon size={20} className="text-[#E8B4C0]" />
</div>
<div className="min-w-0">
<h3 className="text-lg font-bold text-white mb-2 leading-snug">{cluster.title}</h3>
<p className="text-sm text-purple-200/80 leading-relaxed">{cluster.detail}</p>
</div>
</div>
{/* Severity indicator dot */}
<div className="absolute top-4 right-4">
<span className="block w-3 h-3 rounded-full bg-[#C084CF] shadow-[0_0_8px_rgba(192,132,207,0.6)]" />
</div>
</motion.div>
);
})}
</div>
{/* Detailed diagnosis items — compact list below */}
{diagnosis.length > 3 && (
<motion.div
className="rounded-2xl border border-white/10 bg-white/[0.04] backdrop-blur-sm overflow-hidden"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.4 }}
>
<div className="px-6 py-3 border-b border-white/10">
<h4 className="text-xs uppercase tracking-wider text-purple-300/60 font-semibold">
({diagnosis.length})
</h4>
</div>
<div className="divide-y divide-white/5">
{diagnosis.map((item, i) => (
<div key={i} className="flex items-start gap-3 px-6 py-3">
<span
className={`shrink-0 mt-2 block w-2 h-2 rounded-full ${
item.severity === 'critical'
? 'bg-[#E8B4C0]'
: item.severity === 'warning'
? 'bg-[#8B9CF7]'
: item.severity === 'good'
? 'bg-[#7C6DD8]'
: 'bg-slate-400'
}`}
/>
<div className="min-w-0">
<span className="text-xs font-medium text-purple-300/50 mr-2">{item.category}</span>
<span className="text-sm text-purple-100/80">{item.detail}</span>
</div>
</div>
))}
</div>
</motion.div>
)}
</SectionWrapper>
);
}

View File

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

View File

@ -0,0 +1,86 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '@/shared/ui/button';
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) return;
// sticky Navbar (80px) + ReportNav (~48px) 오프셋으로 섹션 상단 가려짐 방지
const STICKY_OFFSET = 128;
const y = el.getBoundingClientRect().top + window.scrollY - STICKY_OFFSET;
window.scrollTo({ top: y, 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
type="button"
variant="ghost"
key={id}
ref={(el) => {
if (el) tabRefs.current.set(id, el);
}}
onClick={() => handleClick(id)}
className={`
shrink-0 px-4 py-3 h-auto text-sm font-medium whitespace-nowrap
border-b-2 rounded-none hover:bg-transparent
${activeId === id
? 'border-brand-purple-vivid text-brand-navy hover:text-brand-navy'
: 'border-transparent text-slate-500 hover:text-slate-700'
}
`}
>
{label}
</Button>
))}
</div>
</nav>
);
}

View File

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

View File

@ -0,0 +1,207 @@
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 { Button } from '@/shared/ui/button';
import type { TransformationProposal as TransformationProposalType, PlatformStrategy } from '@/features/report/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-brand-purple-vivid/10 to-brand-purple/10 flex items-center justify-center">
<Icon size={20} className="text-brand-purple-vivid" />
</div>
<h4 className="font-bold text-brand-navy">{strategy.platform}</h4>
</div>
{/* Current to Target */}
<div className="flex items-center gap-3 mb-4 text-sm">
<span className="rounded-lg bg-brand-rose-bg px-3 py-2 text-brand-rose font-medium">
{strategy.currentMetric}
</span>
<ArrowUpRight size={16} className="text-slate-400" />
<span className="rounded-lg bg-brand-tint-purple px-3 py-2 text-brand-purple-muted 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-brand-purple-vivid mt-2" />
<div>
<p className="text-sm font-medium text-brand-navy">{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-brand-rose-bg text-brand-rose border-brand-rose-soft',
medium: 'bg-brand-earth-bg text-brand-earth border-brand-earth-soft',
low: 'bg-brand-tint-purple text-brand-purple-muted border-brand-tint-lavender',
: 'bg-brand-rose-bg text-brand-rose border-brand-rose-soft',
: 'bg-brand-earth-bg text-brand-earth border-brand-earth-soft',
: 'bg-brand-tint-purple text-brand-purple-muted border-brand-tint-lavender',
};
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)}
variant={activeTab === tab.key ? 'default' : 'outline'}
className={`rounded-full px-4 py-2 h-auto text-sm font-medium ${
activeTab === tab.key
? 'bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white shadow-lg hover:from-brand-purple hover:to-brand-purple-deep'
: 'bg-white 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-brand-navy 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-brand-navy 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-brand-navy 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-brand-navy">{ch.channel}</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center rounded-full text-xs font-medium px-3 py-1 border ${
priorityColor[ch.priority.toLowerCase()] ?? 'bg-slate-50 text-slate-700 border-slate-200'
}`}
>
{ch.priority}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-600">{ch.rationale}</td>
</motion.tr>
))}
</tbody>
</table>
</motion.div>
)}
</SectionWrapper>
);
}

View File

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

View File

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

View File

@ -0,0 +1,28 @@
import type { Severity } from '@/features/report/types/report';
import { SeverityBadge } from './SeverityBadge';
import { EvidenceGallery } from './EvidenceGallery';
interface DiagnosisRowProps {
key?: string | number;
category: string;
detail: string;
severity: Severity;
evidenceIds?: string[];
}
export function DiagnosisRow({ category, detail, severity, evidenceIds }: DiagnosisRowProps) {
return (
<div className="py-4 border-b border-slate-100 last:border-0">
<div className="flex items-center gap-4">
<span className="font-bold text-sm text-[#0A1128] shrink-0 w-28">
{category}
</span>
<p className="flex-1 text-sm text-slate-600">{detail}</p>
<div className="shrink-0">
<SeverityBadge severity={severity} />
</div>
</div>
<EvidenceGallery evidenceIds={evidenceIds} compact />
</div>
);
}

View File

@ -0,0 +1,113 @@
import { motion } from 'motion/react';
import { Search, AlertCircle, Info, RefreshCw } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/shared/ui/button';
type EmptyStatus = 'loading' | 'error' | 'not_found' | 'timeout';
interface EmptyStateProps {
message?: string;
subtext?: string;
status?: EmptyStatus;
onRetry?: () => void;
/** 자동 타임아웃: N초 후 'timeout' 상태로 전환 (기본값: 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: '데이터 수집에 시간이 오래 걸리고 있습니다.',
},
};
/**
*
* (: , , ).
*/
export function EmptyState({
message,
subtext,
status = 'loading',
onRetry,
autoTimeoutSec = 60,
}: EmptyStateProps) {
const [currentStatus, setCurrentStatus] = useState<EmptyStatus>(status);
// 자동 타임아웃: N초 후 'loading'에서 'timeout'으로 전환
useEffect(() => {
if (status !== 'loading') return;
const timer = setTimeout(() => setCurrentStatus('timeout'), autoTimeoutSec * 1000);
return () => clearTimeout(timer);
}, [status, autoTimeoutSec]);
// 외부 status 변경 동기화
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
type="button"
variant="ghost"
size="sm"
onClick={onRetry}
className="mt-4 flex items-center gap-1.5 px-4 py-1.5 h-auto text-xs font-medium text-purple-600 bg-purple-50 rounded-lg hover:bg-purple-100"
>
<RefreshCw size={12} />
</Button>
)}
</motion.div>
);
}

View File

@ -0,0 +1,34 @@
import { useScreenshots } from '@/features/report/stores/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>
);
}

Some files were not shown because too many files have changed in this diff Show More