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
commit
e66b208318
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_BASE_URL=http://localhost:8001
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env*.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
.vite
|
||||||
|
|
@ -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;"]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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: '피부' },
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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="{variant}"</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="destructive")
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { lazy } from 'react'
|
||||||
|
|
||||||
|
const ComponentsPage = lazy(() => import('./pages/ComponentsPage'))
|
||||||
|
|
||||||
|
export const devRoutes = [
|
||||||
|
{ path: 'dev/components', element: <ComponentsPage /> },
|
||||||
|
]
|
||||||
|
|
@ -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' },
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 (Discover→Collect→Report→Plan) 실제 구현 범위에 정직하게 일치시킴. */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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 버튼 위치와 페이지 속도 개선 필요.' },
|
||||||
|
];
|
||||||
|
|
@ -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> (W1→W4). 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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 →
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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> ·{' '}
|
||||||
|
{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'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">→</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -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 = ['월', '화', '수', '목', '금', '토', '일'];
|
||||||
|
|
@ -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초',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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초',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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분',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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개 계정 성과 리뷰 루틴 수립',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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 워드마크 하단 고정',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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분',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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분',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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: '대형 · 멀티 분원 병원',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 /> },
|
||||||
|
]
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { Calendar, Globe, MapPin } from 'lucide-react';
|
||||||
|
import { ScoreRing } from './ui/ScoreRing';
|
||||||
|
|
||||||
|
function formatDate(raw: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(raw).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportHeaderProps {
|
||||||
|
clinicName: string;
|
||||||
|
clinicNameEn: string;
|
||||||
|
overallScore: number;
|
||||||
|
date: string;
|
||||||
|
targetUrl: string;
|
||||||
|
location: string;
|
||||||
|
logoImage?: string;
|
||||||
|
brandColors?: { primary: string; accent: string; text: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReportHeader({
|
||||||
|
clinicName,
|
||||||
|
logoImage,
|
||||||
|
brandColors,
|
||||||
|
clinicNameEn,
|
||||||
|
overallScore,
|
||||||
|
date,
|
||||||
|
targetUrl,
|
||||||
|
location,
|
||||||
|
}: ReportHeaderProps) {
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 px-6">
|
||||||
|
{/* Animated blobs */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
|
||||||
|
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
|
||||||
|
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
|
||||||
|
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
|
||||||
|
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl"
|
||||||
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||||
|
{/* Left: Text content */}
|
||||||
|
<motion.div
|
||||||
|
className="flex-1 text-center md:text-left"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
>
|
||||||
|
<motion.p
|
||||||
|
className="font-serif text-3xl md:text-4xl font-normal text-[#6C5CE7] mb-4 tracking-wide"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
>
|
||||||
|
Marketing Intelligence Report
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
{logoImage && (
|
||||||
|
<motion.div
|
||||||
|
className="mb-4"
|
||||||
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.15 }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={logoImage}
|
||||||
|
alt={clinicName}
|
||||||
|
className="h-16 md:h-20 w-auto object-contain md:mx-0 mx-auto"
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<motion.h1
|
||||||
|
className="font-serif text-4xl md:text-5xl font-bold text-[#0A1128] mb-3"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{clinicName}
|
||||||
|
</motion.h1>
|
||||||
|
|
||||||
|
<motion.p
|
||||||
|
className="text-lg text-slate-600 mb-8"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
{clinicNameEn}
|
||||||
|
</motion.p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex flex-wrap gap-3 justify-center md:justify-start"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.4 }}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
|
||||||
|
<Calendar size={14} className="text-slate-400" />
|
||||||
|
{formatDate(date)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
|
||||||
|
<Globe size={14} className="text-slate-400" />
|
||||||
|
{targetUrl}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
|
||||||
|
<MapPin size={14} className="text-slate-400" />
|
||||||
|
{location}
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Right: Score ring */}
|
||||||
|
<motion.div
|
||||||
|
className="shrink-0"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="bg-white/60 backdrop-blur-sm border border-white/40 rounded-3xl p-8 shadow-lg">
|
||||||
|
<p className="text-xs text-slate-500 uppercase tracking-wide text-center mb-4">
|
||||||
|
Overall Score
|
||||||
|
</p>
|
||||||
|
<ScoreRing score={overallScore} size={160} label="종합 점수" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
interface ComparisonRowProps {
|
||||||
|
key?: string | number;
|
||||||
|
area: string;
|
||||||
|
asIs: string;
|
||||||
|
toBe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[120px_1fr_1fr] gap-3 items-start py-4 border-b border-slate-100 last:border-0">
|
||||||
|
<span className="font-medium text-sm text-slate-700 pt-3">{area}</span>
|
||||||
|
<div className="bg-[#FFF0F0]/50 rounded-lg p-3 text-sm text-slate-600">
|
||||||
|
{asIs}
|
||||||
|
</div>
|
||||||
|
<div className="bg-[#F3F0FF]/50 rounded-lg p-3 text-sm text-slate-700 font-medium">
|
||||||
|
{toBe}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { Severity } from '@/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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue