Compare commits
No commits in common. "master" and "feature/home" have entirely different histories.
master
...
feature/ho
31
README.md
|
|
@ -10,36 +10,25 @@
|
|||
|
||||
## 디렉토리 구조
|
||||
|
||||
디렉토리 구조는 다음을 따를 예정입니다.
|
||||
```bash
|
||||
src/
|
||||
├── app/ # 애플리케이션 진입점 및 전역 설정 (Router, Providers, 글로벌 스타일)
|
||||
│ └── providers/ # React Context Provider 모음 (QueryProvider 등)
|
||||
├── assets/ # 정적 파일 (이미지, 폰트, SVG 아이콘 등)
|
||||
├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 카드, 디자인 시스템)
|
||||
├── assets/ # 정적 파일 (이미지, 폰트, 로티 애니메이션 등)
|
||||
├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 모달, 디자인 시스템)
|
||||
├── features/ # 핵심 비즈니스 로직 및 도메인 영역 (이 구조의 핵심)
|
||||
│ ├── home/ # 홈(랜딩) 도메인
|
||||
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
|
||||
│ ├── auth/ # 특정 도메인 (예: 인증)
|
||||
│ │ ├── api/ # 해당 도메인 전용 API 통신 함수
|
||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
||||
│ ├── report/ # 리포트 도메인
|
||||
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
|
||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||
│ │ ├── mocks/ # API 연동 전 임시 목업 데이터
|
||||
│ │ ├── store/ # 해당 도메인 전용 상태 (Zustand 등)
|
||||
│ │ ├── types/ # 해당 도메인 전용 타입 정의
|
||||
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
||||
│ └── plan/ # 마케팅 플랜 도메인 (report와 동일한 구조)
|
||||
│ ├── config/
|
||||
│ ├── hooks/
|
||||
│ ├── mocks/
|
||||
│ ├── types/
|
||||
│ └── ui/
|
||||
├── hooks/ # 전역에서 사용하는 공통 훅 (useInView 등)
|
||||
├── layouts/ # 페이지 레이아웃 (GNB, SubNav, Footer 등)
|
||||
├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (features의 컴포넌트만 조립)
|
||||
├── hooks/ # 전역에서 사용하는 공통 훅 (useClickOutside 등)
|
||||
├── layouts/ # 페이지 레이아웃 (GNB, Sidebar, 풋터 등)
|
||||
├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (여기서는 features의 컴포넌트만 조립)
|
||||
├── services/ # 공통 API 클라이언트 설정 (Axios 인스턴스, 인터셉터 등)
|
||||
├── store/ # 전역 상태 관리 (사용자 세션, 테마 등)
|
||||
├── types/ # 여러 도메인에서 공유하는 공통 타입 정의
|
||||
└── utils/ # 공통 유틸리티 함수 (숫자 포맷팅, URL 처리 등)
|
||||
└── utils/ # 공통 유틸리티 함수 (날짜 포맷팅, 정규식 등)
|
||||
```
|
||||
|
||||
## 시작하기
|
||||
|
|
|
|||
|
|
@ -19,14 +19,5 @@ export default defineConfig([
|
|||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
import { Routes, Route } from "react-router-dom";
|
||||
// layouts
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
||||
|
||||
// pages
|
||||
import { Home } from "@/pages/Home";
|
||||
import { PlanPage } from "@/pages/Plan";
|
||||
import { ReportPage } from "@/pages/Report";
|
||||
|
||||
function App() {
|
||||
|
||||
|
|
@ -15,10 +12,6 @@ function App() {
|
|||
<Route element={<MainLayout />}>
|
||||
<Route index element={<Home />} />
|
||||
</Route>
|
||||
<Route element={<MainSubNavLayout />}>
|
||||
<Route path="report/:id" element={<ReportPage />} />
|
||||
<Route path="plan/:id" element={<PlanPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,27 +130,12 @@
|
|||
|
||||
/* ─── Utility Classes ─────────────────────────────────────────────── */
|
||||
|
||||
/* 라이트 섹션 헤딩 그라디언트 텍스트
|
||||
- base의 h2 { color: navy }와 함께 쓸 때 color를 반드시 투명으로 두어야 그라데이션이 보임
|
||||
- background 단축 속성은 clip을 리셋할 수 있어 background-image만 사용 */
|
||||
/* 라이트 섹션 헤딩 그라디언트 텍스트 */
|
||||
.text-gradient {
|
||||
color: transparent;
|
||||
background-image: linear-gradient(to right, var(--color-navy-900), #3b5998);
|
||||
background: linear-gradient(to right, var(--color-navy-900), #3b5998);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* 그라데이션 제목(h2)과 같은 레이아웃 안의 뱃지·버튼:
|
||||
* WebKit 계열에서 -webkit-text-fill-color 가 상속되면 text-* 만으로는 글씨가 비어 보일 수 있음.
|
||||
* currentColor 는 해당 요소의 (Tailwind) color 값과 맞춘다.
|
||||
*/
|
||||
.solid-text-paint {
|
||||
-webkit-text-fill-color: currentColor !important;
|
||||
/* bg-* 와 같은 요소에 clip:text가 남으면 글리프가 배경으로만 채워져 비어 보일 수 있음 */
|
||||
-webkit-background-clip: border-box !important;
|
||||
background-clip: border-box !important;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* 반투명 글래스 카드 — 랜딩 섹션 */
|
||||
|
|
@ -165,7 +150,7 @@
|
|||
|
||||
/* 브랜드 그라디언트 Primary 버튼 */
|
||||
.btn-primary {
|
||||
@apply cursor-pointer bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
||||
@apply bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
||||
}
|
||||
|
||||
/* ─── Typography Scale ────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 228 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 226 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="8" r="6" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M8.21 13.89 7 23l5-3 5 3-1.21-9.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 423 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 329 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" />
|
||||
<path d="m21 21-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 269 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 485 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 281 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 189 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 511 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 225 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22 7 13.5 15.5 8.5 10.5 2 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 7h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm11-4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 336 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 429 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7 17L17 7M17 7H9M17 7V15"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 271 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 294 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,3 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 238 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 3v12m0 0l4-4m-4 4L8 11M5 21h14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 280 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 454 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 285 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path
|
||||
d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4M12 17h.01"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 367 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 301 B |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 347 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M7.9 20A9 9 0 1 0 4 16.1L2 22l5.9-2z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 282 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 293 B |
|
|
@ -1,33 +0,0 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||
|
||||
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
variant?: "primary" | "outline";
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = "",
|
||||
variant = "outline",
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
"rounded-full transition-all cursor-pointer break-keep solid-text-paint",
|
||||
variant === "primary"
|
||||
? `${UI_PRIMARY_GRADIENT_CLASS} text-white shadow-md`
|
||||
: "bg-white border border-neutral-30 text-neutral-70 hover:bg-neutral-10",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type PillProps = Omit<HTMLAttributes<HTMLSpanElement>, "className"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
weight?: "medium" | "semibold" | "none";
|
||||
inlineFlex?: boolean;
|
||||
};
|
||||
|
||||
export function Pill({
|
||||
children,
|
||||
className = "",
|
||||
size = "md",
|
||||
weight = "medium",
|
||||
inlineFlex = false,
|
||||
...rest
|
||||
}: PillProps) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"rounded-full break-keep",
|
||||
inlineFlex && "inline-flex items-center",
|
||||
size === "sm" ? "px-2 py-1" : "px-3 py-1",
|
||||
weight === "semibold" ? "font-semibold" : weight === "medium" ? "font-medium" : "",
|
||||
"label-12",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{/* 배경·테두리는 바깥 span, WebKit 글자 채움은 안쪽만 solid-text-paint */}
|
||||
<span className="solid-text-paint">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
const paddingMap = {
|
||||
none: "",
|
||||
sm: "p-4",
|
||||
md: "p-5",
|
||||
lg: "p-6",
|
||||
} as const;
|
||||
|
||||
export type SurfaceProps = Omit<HTMLAttributes<HTMLDivElement>, "className"> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padding?: keyof typeof paddingMap;
|
||||
radius?: "xl" | "2xl";
|
||||
bordered?: boolean;
|
||||
surface?: "white" | "none";
|
||||
shadow?: boolean;
|
||||
interactive?: boolean | "lift";
|
||||
overflowHidden?: boolean;
|
||||
};
|
||||
|
||||
export function Surface({
|
||||
children,
|
||||
className = "",
|
||||
padding = "md",
|
||||
radius = "2xl",
|
||||
bordered = true,
|
||||
surface = "white",
|
||||
shadow = true,
|
||||
interactive = false,
|
||||
overflowHidden = false,
|
||||
...rest
|
||||
}: SurfaceProps) {
|
||||
const interactiveClass =
|
||||
interactive === "lift"
|
||||
? "hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow"
|
||||
: interactive
|
||||
? "hover:shadow-md transition-shadow"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
radius === "xl" ? "rounded-xl" : "rounded-2xl",
|
||||
bordered && "border border-neutral-20",
|
||||
surface === "white" && "bg-white",
|
||||
shadow && "card-shadow",
|
||||
paddingMap[padding],
|
||||
interactiveClass,
|
||||
overflowHidden && "overflow-hidden",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
export { Button, type ButtonProps } from "@/components/atoms/Button";
|
||||
export { Pill, type PillProps } from "@/components/atoms/Pill";
|
||||
export { Surface, type SurfaceProps } from "@/components/atoms/Surface";
|
||||
export { UI_PRIMARY_GRADIENT_CLASS } from "@/components/atoms/uiTokens";
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */
|
||||
export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950";
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import type { Severity } from "@/types/severity";
|
||||
|
||||
export type SeverityBadgeProps = {
|
||||
severity: Severity;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const config: Record<Severity, { className: string; defaultLabel: string }> = {
|
||||
critical: {
|
||||
className:
|
||||
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]",
|
||||
defaultLabel: "심각",
|
||||
},
|
||||
warning: {
|
||||
className:
|
||||
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]",
|
||||
defaultLabel: "주의",
|
||||
},
|
||||
good: {
|
||||
className:
|
||||
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]",
|
||||
defaultLabel: "양호",
|
||||
},
|
||||
excellent: {
|
||||
className:
|
||||
"bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border-[var(--color-status-info-border)]",
|
||||
defaultLabel: "우수",
|
||||
},
|
||||
unknown: {
|
||||
className: "bg-neutral-10 text-neutral-80 border-neutral-20",
|
||||
defaultLabel: "미확인",
|
||||
},
|
||||
};
|
||||
|
||||
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
|
||||
const { className, defaultLabel } = config[severity];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full label-12 font-medium px-3 py-1 border ${className}`}
|
||||
>
|
||||
{label ?? defaultLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react";
|
||||
import ChevronRightIcon from "@/assets/report/chevron-right.svg?react";
|
||||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
import type { BrandInconsistency } from "@/types/brandConsistency";
|
||||
|
||||
export type BrandConsistencyMapProps = {
|
||||
inconsistencies: BrandInconsistency[];
|
||||
className?: string;
|
||||
/** false면 상단 제목·설명을 숨김 (플랜 브랜딩 가이드 탭 등) */
|
||||
showHeading?: boolean;
|
||||
};
|
||||
|
||||
export function BrandConsistencyMap({
|
||||
inconsistencies,
|
||||
className = "",
|
||||
showHeading = true,
|
||||
}: BrandConsistencyMapProps) {
|
||||
const [expanded, setExpanded] = useState<number | null>(0);
|
||||
|
||||
if (inconsistencies.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${showHeading ? "mt-8 animate-fade-in-up animation-delay-200 " : ""}${className}`.trim()}
|
||||
>
|
||||
{showHeading ? (
|
||||
<>
|
||||
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
|
||||
<p className="body-14 text-neutral-60 mb-5 break-keep">전 채널 브랜드 일관성 분석</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
{inconsistencies.map((item, i) => {
|
||||
const wrongCount = item.values.filter((v) => !v.isCorrect).length;
|
||||
const isOpen = expanded === i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.field}
|
||||
className="rounded-2xl border border-neutral-20 bg-white card-shadow overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-3 p-5 text-left hover:bg-neutral-10/80 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-navy-900 flex items-center justify-center text-white label-12-semibold shrink-0">
|
||||
{wrongCount}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="title-14 text-navy-900 break-keep">{item.field}</p>
|
||||
<p className="label-12 text-neutral-60 break-keep">{wrongCount}개 채널 불일치</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className={`text-neutral-60 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="px-5 pb-5 border-t border-neutral-20">
|
||||
<div className="grid gap-2 mt-4 mb-4">
|
||||
{item.values.map((v) => (
|
||||
<div
|
||||
key={v.channel}
|
||||
className={`flex flex-wrap items-center justify-between gap-2 py-3 px-3 rounded-lg body-14 sm:flex-nowrap ${
|
||||
v.isCorrect
|
||||
? "bg-[var(--color-status-good-bg)]/60"
|
||||
: "bg-[var(--color-status-critical-bg)]/60"
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-neutral-80 shrink-0 min-w-[100px] break-keep">
|
||||
{v.channel}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-1 text-right min-w-0 break-keep ${
|
||||
v.isCorrect
|
||||
? "text-[var(--color-status-good-text)]"
|
||||
: "text-[var(--color-status-critical-text)]"
|
||||
}`}
|
||||
>
|
||||
{v.value}
|
||||
</span>
|
||||
<span className="ml-auto sm:ml-3 shrink-0">
|
||||
{v.isCorrect ? (
|
||||
<CheckCircleIcon
|
||||
width={15}
|
||||
height={15}
|
||||
className="text-[var(--color-status-good-dot)]"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<XCircleIcon
|
||||
width={15}
|
||||
height={15}
|
||||
className="text-[var(--color-status-critical-dot)]"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-[var(--color-status-warning-bg)] border border-[var(--color-status-warning-border)] p-4 mb-3">
|
||||
<p className="label-12-semibold text-[var(--color-status-warning-text)] uppercase tracking-wide mb-1 break-keep">
|
||||
<AlertCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
||||
Impact
|
||||
</p>
|
||||
<p className="body-14 text-[var(--color-status-warning-text)] break-keep">{item.impact}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)] p-4">
|
||||
<p className="label-12-semibold text-[var(--color-status-good-text)] uppercase tracking-wide mb-1 break-keep">
|
||||
<CheckCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
||||
Recommendation
|
||||
</p>
|
||||
<p className="body-14 text-[var(--color-status-good-text)] break-keep">{item.recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { SeverityBadge } from "@/components/badge/SeverityBadge";
|
||||
import { ScoreRing } from "@/components/rating/ScoreRing";
|
||||
import type { Severity } from "@/types/severity";
|
||||
|
||||
export type ChannelScoreCardProps = {
|
||||
channel: string;
|
||||
icon: ReactNode;
|
||||
/** 아이콘·점수 링에 쓰는 브랜드/강조 색 (없으면 링은 점수대비 자동색) */
|
||||
accentColor?: string;
|
||||
score: number;
|
||||
maxScore: number;
|
||||
headline: string;
|
||||
severity: Severity;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function ChannelScoreCard({
|
||||
channel,
|
||||
icon,
|
||||
accentColor,
|
||||
score,
|
||||
maxScore,
|
||||
headline,
|
||||
severity,
|
||||
className = "",
|
||||
style,
|
||||
}: ChannelScoreCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-4 text-center flex flex-col items-center gap-3 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl bg-neutral-10 flex items-center justify-center shrink-0 [&_svg]:block ${accentColor ? "" : "text-neutral-60"}`}
|
||||
style={accentColor ? { color: accentColor } : undefined}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<p className="body-14-medium text-navy-900 break-keep px-1">{channel}</p>
|
||||
<ScoreRing score={score} maxScore={maxScore} size={60} color={accentColor} className="gap-1" />
|
||||
<p className="label-12 text-neutral-60 line-clamp-2 leading-relaxed min-h-8 break-keep px-1">{headline}</p>
|
||||
<SeverityBadge severity={severity} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
export type InfoStatCardProps = {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function InfoStatCard({ icon, label, value, className = "", style }: InfoStatCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-5 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-neutral-10 flex items-center justify-center text-neutral-60">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="label-12 text-neutral-60 uppercase tracking-wide break-keep">{label}</p>
|
||||
<p className="title-18 text-navy-900 mt-1 break-keep">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import ArrowDownIcon from "@/assets/icons/arrow-down.svg?react";
|
||||
import ArrowUpIcon from "@/assets/icons/arrow-up.svg?react";
|
||||
import MinusIcon from "@/assets/icons/minus.svg?react";
|
||||
|
||||
export type MetricCardProps = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtext?: string;
|
||||
icon?: ReactNode;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
};
|
||||
|
||||
const trendConfig = {
|
||||
up: { Icon: ArrowUpIcon, className: "text-violet-600" },
|
||||
down: { Icon: ArrowDownIcon, className: "text-[var(--color-status-critical-text)]" },
|
||||
neutral: { Icon: MinusIcon, className: "text-neutral-60" },
|
||||
} as const;
|
||||
|
||||
export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) {
|
||||
const TrendGlyph = trend ? trendConfig[trend].Icon : null;
|
||||
const trendColor = trend ? trendConfig[trend].className : "";
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-neutral-20 shadow-sm bg-white p-5 relative animate-fade-in-up">
|
||||
{icon ? <div className="absolute top-4 right-4 text-neutral-40 [&_svg]:block">{icon}</div> : null}
|
||||
<p className="body-14 text-neutral-60 mb-1 break-keep">{label}</p>
|
||||
<div className="flex items-end gap-2 min-w-0">
|
||||
<span className="text-3xl font-bold text-navy-900 break-keep">{value}</span>
|
||||
{trend && TrendGlyph ? (
|
||||
<span className={`mb-1 ${trendColor}`}>
|
||||
<TrendGlyph width={18} height={18} aria-hidden />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{subtext ? <p className="label-12 text-neutral-60 mt-1 break-keep">{subtext}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
|
||||
export type PixelInstallCardProps = {
|
||||
name: string;
|
||||
installed: boolean;
|
||||
details?: string;
|
||||
};
|
||||
|
||||
export function PixelInstallCard({ name, installed, details }: PixelInstallCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-xl p-3 min-w-0 ${
|
||||
installed
|
||||
? "bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)]"
|
||||
: "bg-[var(--color-status-critical-bg)] border border-[var(--color-status-critical-border)]"
|
||||
}`}
|
||||
>
|
||||
{installed ? (
|
||||
<CheckCircleIcon width={16} height={16} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
||||
) : (
|
||||
<XCircleIcon width={16} height={16} className="text-[var(--color-status-critical-dot)] shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className={`body-14-medium break-keep ${
|
||||
installed ? "text-[var(--color-status-good-text)]" : "text-[var(--color-status-critical-text)]"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{details ? (
|
||||
<p className="label-12 text-neutral-60 break-keep min-w-0">{details}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import EyeIcon from "@/assets/icons/eye.svg?react";
|
||||
import { formatCompactNumber } from "@/utils/formatNumber";
|
||||
|
||||
export type TopVideoCardProps = {
|
||||
title: string;
|
||||
views: number;
|
||||
uploadedAgo: string;
|
||||
type: "Short" | "Long";
|
||||
duration?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function TopVideoCard({
|
||||
title,
|
||||
views,
|
||||
uploadedAgo,
|
||||
type,
|
||||
duration,
|
||||
className = "",
|
||||
style,
|
||||
}: TopVideoCardProps) {
|
||||
const typeClass =
|
||||
type === "Short"
|
||||
? "bg-lavender-100 text-violet-700"
|
||||
: "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)]";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl bg-white border border-neutral-20 shadow-sm p-4 min-w-[250px] shrink-0 animate-fade-in-up ${className}`.trim()}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`label-12 font-medium px-2 py-1 rounded-full break-keep ${typeClass}`}>{type}</span>
|
||||
{duration ? <span className="label-12 text-neutral-60 break-keep">{duration}</span> : null}
|
||||
<span className="label-12 text-neutral-60 break-keep">{uploadedAgo}</span>
|
||||
</div>
|
||||
<p className="body-14-medium text-navy-900 line-clamp-2 mb-2 break-keep">{title}</p>
|
||||
<div className="flex items-center gap-1 body-14 text-neutral-70 min-w-0">
|
||||
<EyeIcon width={14} height={14} className="text-neutral-60 shrink-0" aria-hidden />
|
||||
<span className="title-14 break-keep">{formatCompactNumber(views)}</span>
|
||||
<span className="text-neutral-60 break-keep">views</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
export type TagChipListProps = {
|
||||
tags: string[];
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** 기본 `animation-delay-400`. 빈 문자열이면 지연 없음 */
|
||||
entranceDelayClass?: string;
|
||||
};
|
||||
|
||||
export function TagChipList({
|
||||
tags,
|
||||
title,
|
||||
className = "",
|
||||
entranceDelayClass = "animation-delay-400",
|
||||
}: TagChipListProps) {
|
||||
if (!tags.length) return null;
|
||||
|
||||
return (
|
||||
<div className={`animate-fade-in-up ${entranceDelayClass} ${className}`.trim()}>
|
||||
{title ? <p className="body-14-medium text-neutral-80 mb-3 break-keep">{title}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-white/60 backdrop-blur-sm border border-neutral-20 px-3 py-1 body-14-medium text-neutral-80 break-keep"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
export { BrandConsistencyMap, type BrandConsistencyMapProps } from "@/components/brand/BrandConsistencyMap";
|
||||
export { SeverityBadge, type SeverityBadgeProps } from "@/components/badge/SeverityBadge";
|
||||
export { ChannelScoreCard, type ChannelScoreCardProps } from "@/components/card/ChannelScoreCard";
|
||||
export { InfoStatCard, type InfoStatCardProps } from "@/components/card/InfoStatCard";
|
||||
export { MetricCard, type MetricCardProps } from "@/components/card/MetricCard";
|
||||
export { PixelInstallCard, type PixelInstallCardProps } from "@/components/card/PixelInstallCard";
|
||||
export { TopVideoCard, type TopVideoCardProps } from "@/components/card/TopVideoCard";
|
||||
export { TagChipList, type TagChipListProps } from "@/components/chip/TagChipList";
|
||||
export { ConsolidationCallout, type ConsolidationCalloutProps } from "@/components/panel/ConsolidationCallout";
|
||||
export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/HighlightPanel";
|
||||
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
|
||||
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
|
||||
export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
|
||||
export {
|
||||
Button,
|
||||
Pill,
|
||||
Surface,
|
||||
UI_PRIMARY_GRADIENT_CLASS,
|
||||
type ButtonProps,
|
||||
type PillProps,
|
||||
type SurfaceProps,
|
||||
} from "@/components/atoms";
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type ConsolidationCalloutProps = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 통합·전략 권고 등 강조 CTA 블록 — 리포트 채널 섹션 공통 */
|
||||
export function ConsolidationCallout({
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
className = "",
|
||||
}: ConsolidationCalloutProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mt-8 rounded-2xl bg-gradient-to-r from-violet-700 to-navy-950 p-6 md:p-8 text-white animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{icon ? (
|
||||
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 text-white [&_svg]:block">
|
||||
{icon}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h4 className="font-serif headline-24 mb-2 break-keep">{title}</h4>
|
||||
<div className="body-14 text-lavender-200 leading-relaxed break-keep">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type HighlightPanelProps = {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function HighlightPanel({ title, icon, className = "", children }: HighlightPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-2xl bg-gradient-to-r from-violet-700/5 to-navy-950/5 border border-lavender-200 p-6 animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon ? <span className="text-violet-700 shrink-0 [&_svg]:block">{icon}</span> : null}
|
||||
<h3 className="font-serif headline-20 text-navy-900">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export type ScoreRingProps = {
|
||||
score: number;
|
||||
maxScore?: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
/** 링 색 (브랜드 등). 없으면 점수 구간별 자동 색 */
|
||||
color?: string;
|
||||
className?: string;
|
||||
scoreClassName?: string;
|
||||
};
|
||||
|
||||
function scoreStrokeColor(score: number, maxScore: number): string {
|
||||
const pct = (score / maxScore) * 100;
|
||||
if (pct <= 40) return "#D4889A";
|
||||
if (pct <= 60) return "#7A84D4";
|
||||
if (pct <= 80) return "#9B8AD4";
|
||||
return "#6C5CE7";
|
||||
}
|
||||
|
||||
export function ScoreRing({
|
||||
score,
|
||||
maxScore = 100,
|
||||
size = 120,
|
||||
label,
|
||||
color,
|
||||
className = "",
|
||||
scoreClassName,
|
||||
}: ScoreRingProps) {
|
||||
const strokeWidth = size <= 72 ? 5 : 8;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = Math.min(score / maxScore, 1);
|
||||
const targetOffset = circumference * (1 - progress);
|
||||
const resolvedColor = color ?? scoreStrokeColor(score, maxScore);
|
||||
|
||||
const [dashOffset, setDashOffset] = useState(circumference);
|
||||
|
||||
const defaultScoreClass =
|
||||
size <= 72 ? "text-sm font-bold text-navy-900" : "text-2xl font-bold text-navy-900";
|
||||
|
||||
useEffect(() => {
|
||||
setDashOffset(circumference);
|
||||
const id = requestAnimationFrame(() => setDashOffset(targetOffset));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [circumference, targetOffset]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-center gap-2 ${className}`.trim()}>
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="-rotate-90"
|
||||
aria-hidden
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
className="stroke-neutral-20"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={resolvedColor}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
style={{ transition: "stroke-dashoffset 1s ease-out" }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className={scoreClassName ?? defaultScoreClass}>{score}</span>
|
||||
</div>
|
||||
</div>
|
||||
{label ? <span className="body-14 text-neutral-60 text-center">{label}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import StarIcon from "@/assets/icons/star.svg?react";
|
||||
import { formatCompactNumber } from "@/utils/formatNumber";
|
||||
|
||||
export type StarRatingDisplayProps = {
|
||||
rating: number;
|
||||
maxStars?: number;
|
||||
reviewCount?: number;
|
||||
formatCount?: (n: number) => string;
|
||||
};
|
||||
|
||||
export function StarRatingDisplay({
|
||||
rating,
|
||||
maxStars = 5,
|
||||
reviewCount,
|
||||
formatCount = formatCompactNumber,
|
||||
}: StarRatingDisplayProps) {
|
||||
const filled = Math.min(maxStars, Math.max(0, Math.round(rating)));
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: maxStars }, (_, i) => (
|
||||
<StarIcon
|
||||
key={i}
|
||||
className={i < filled ? "text-violet-700" : "text-neutral-20"}
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
<span className="body-14-medium text-navy-900 ml-1">{rating}</span>
|
||||
</div>
|
||||
{reviewCount != null ? (
|
||||
<span className="body-14 text-neutral-60">리뷰 {formatCount(reviewCount)}건</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type PageSectionProps = {
|
||||
id?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
dark?: boolean;
|
||||
className?: string;
|
||||
/** false면 섹션 입장용 `animate-fade-in-up` 미적용 (DEMO에 없는 모션을 쓰지 않을 때) */
|
||||
animateEnter?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function PageSection({
|
||||
id,
|
||||
title,
|
||||
subtitle,
|
||||
dark = false,
|
||||
className = "",
|
||||
animateEnter = true,
|
||||
children,
|
||||
}: PageSectionProps) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={[
|
||||
"py-16 md:py-20 px-6 scroll-mt-36",
|
||||
animateEnter ? "animate-fade-in-up" : "",
|
||||
dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{dark ? (
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.15),transparent_60%)] pointer-events-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={[
|
||||
"relative max-w-7xl mx-auto",
|
||||
dark ? "text-white" : "text-neutral-80",
|
||||
].join(" ")}
|
||||
>
|
||||
<header className="mb-10">
|
||||
<h2
|
||||
className={
|
||||
dark
|
||||
? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent"
|
||||
: "font-serif text-3xl md:display-36 font-bold mb-3 text-transparent text-gradient"
|
||||
}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p
|
||||
className={
|
||||
dark
|
||||
? "body-18 text-lavender-200 break-keep solid-text-paint"
|
||||
: "body-18 text-neutral-70 break-keep solid-text-paint"
|
||||
}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
CTA_FOOTNOTE,
|
||||
CTA_HEADLINE,
|
||||
CTA_URL_PLACEHOLDER,
|
||||
} from "@/features/home/content/cta";
|
||||
} from "@/features/home/constants/cta_contents";
|
||||
import { useAnalyze } from "@/features/home/hooks/useAnalyze";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
HERO_LEAD_EN,
|
||||
HERO_LEAD_KO,
|
||||
HERO_URL_PLACEHOLDER,
|
||||
} from "@/features/home/content/hero";
|
||||
} from "@/features/home/constants/hero_contents";
|
||||
import { useAnalyze } from "@/features/home/hooks/useAnalyze";
|
||||
|
||||
export function HeroSection() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/content/problem";
|
||||
import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/constants/problem_contents";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
|
||||
export function ProblemSection() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { SOLUTION_CARDS } from "@/features/home/content/solution";
|
||||
import { SOLUTION_CARDS } from "@/features/home/constants/solution_contents";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
|
||||
export function SolutionSection() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/content/modules";
|
||||
import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/constants/modules_contents";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
import { CoreModuleCard } from "./system/CoreModuleCard";
|
||||
import { CoreModulesCenterHeading } from "./system/CoreModulesCenterHeading";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import CheckCircleIcon from "@/assets/home/check-circle.svg?react";
|
||||
import { USE_CASE_CARDS } from "@/features/home/content/useCases";
|
||||
import { USE_CASE_CARDS } from "@/features/home/constants/use_cases_contents";
|
||||
import { useInView } from "@/hooks/useInView";
|
||||
|
||||
export function UseCaseSection() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { AGDP_NODES } from "@/features/home/content/process";
|
||||
import { AGDP_NODES } from "@/features/home/constants/process_contents";
|
||||
import { AgdpOrbitNode } from "./AgdpOrbitNode";
|
||||
import { AgdpRewardPathLabel } from "./AgdpRewardPathLabel";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { AgdpNodeDef } from "@/features/home/content/process";
|
||||
import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/content/process";
|
||||
import type { AgdpNodeDef } from "@/features/home/constants/process_contents";
|
||||
import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/constants/process_contents";
|
||||
|
||||
type Props = { node: AgdpNodeDef };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CoreModule } from "@/features/home/content/modules";
|
||||
import type { CoreModule } from "@/features/home/constants/modules_contents";
|
||||
|
||||
type Props = {
|
||||
mod: CoreModule;
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* SubNav·IntersectionObserver와 각 섹션 `id`가 일치해야 합니다.
|
||||
* (`DEMO/src/pages/MarketingPlanPage.tsx`의 PLAN_SECTIONS와 동일)
|
||||
*/
|
||||
export const PLAN_SECTIONS = [
|
||||
{ id: "branding-guide", label: "브랜딩 가이드" },
|
||||
{ id: "channel-strategy", label: "채널 전략" },
|
||||
{ id: "content-strategy", label: "콘텐츠 전략" },
|
||||
{ id: "content-calendar", label: "콘텐츠 캘린더" },
|
||||
{ id: "asset-collection", label: "에셋 수집" },
|
||||
{ id: "my-asset-upload", label: "My Assets" },
|
||||
] as const;
|
||||
|
||||
export type PlanSectionId = (typeof PLAN_SECTIONS)[number]["id"];
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
import { useMarketingPlan } from "@/features/plan/hooks/useMarketingPlan";
|
||||
|
||||
/**
|
||||
* 현재 `plan/:id` 라우트의 마케팅 플랜.
|
||||
* Zustand 등 전역 소스로 바꿀 때는 이 훅만 수정하면 섹션들은 그대로 둘 수 있습니다.
|
||||
*/
|
||||
export function useCurrentMarketingPlan() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return useMarketingPlan(id);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import { MOCK_PLAN } from "@/features/plan/mocks/plan";
|
||||
import type { MarketingPlan } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
type UseMarketingPlanResult = {
|
||||
data: MarketingPlan | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Phase 1: 항상 `MOCK_PLAN` 반환.
|
||||
* `_planRouteId`는 `plan/:id`·추후 API 조회에 사용 예정(현재 목만 반환).
|
||||
*/
|
||||
export function useMarketingPlan(_planRouteId: string | undefined): UseMarketingPlanResult {
|
||||
return useMemo(
|
||||
() => ({ data: MOCK_PLAN, isLoading: false, error: null }),
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMainSubNav } from "@/layouts/MainSubNavLayout";
|
||||
import type { SubNavItem } from "@/layouts/SubNav";
|
||||
import { PLAN_SECTIONS } from "@/features/plan/config/planSections";
|
||||
|
||||
export function usePlanSubNav() {
|
||||
const { setSubNav } = useMainSubNav();
|
||||
const [activeId, setActiveId] = useState<string>(PLAN_SECTIONS[0]?.id ?? "");
|
||||
|
||||
const items: SubNavItem[] = useMemo(
|
||||
() =>
|
||||
PLAN_SECTIONS.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
targetId: s.id,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
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 }
|
||||
);
|
||||
|
||||
PLAN_SECTIONS.forEach(({ id: sectionId }) => {
|
||||
const el = document.getElementById(sectionId);
|
||||
if (el) observer.observe(el);
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSubNav({
|
||||
items,
|
||||
activeId,
|
||||
scrollActiveIntoView: true,
|
||||
});
|
||||
}, [activeId, items, setSubNav]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setSubNav(null);
|
||||
}, [setSubNav]);
|
||||
}
|
||||
|
|
@ -1,272 +0,0 @@
|
|||
import type { MarketingPlan } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
export const MOCK_PLAN: MarketingPlan = {
|
||||
id: 'view-clinic',
|
||||
reportId: 'view-clinic',
|
||||
clinicName: '뷰성형외과의원',
|
||||
clinicNameEn: 'VIEW Plastic Surgery',
|
||||
createdAt: '2026-03-22',
|
||||
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: '103K 구독자, 주 1회 업로드', 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: '14K 팔로워, 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: '68.8K 팔로워, Reels 활발', targetGoal: '100K 팔로워',
|
||||
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 253명 + EN 88K, 로고 불일치', targetGoal: '통합 관리, 광고 리타겟 전용',
|
||||
contentTypes: ['광고 크리에이티브', '리타겟 콘텐츠'],
|
||||
postingFrequency: '주 2-3회 (광고 소재 위주)',
|
||||
tone: '신뢰 기반 — 안전, 경험, 결과 강조',
|
||||
formatGuidelines: ['KR 페이지 폐쇄 → EN 페이지로 통합', 'Facebook Pixel 리타겟 광고 최적화', '로고 VIEW 골드로 즉시 교체'],
|
||||
priority: 'P1',
|
||||
},
|
||||
{
|
||||
channelId: 'naver_blog', channelName: 'Naver Blog', icon: 'globe',
|
||||
currentStatus: '미확인 / 미운영', targetGoal: '월 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',
|
||||
},
|
||||
],
|
||||
|
||||
// ─── 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: '네이버 블로그 기존 포스트', description: '기존 블로그 포스트 (수량 미확인)', repurposingSuggestions: ['SEO 최적화 리라이팅', '영상 스크립트 소스'], status: 'pending' },
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import type { BrandInconsistency } from "@/types/brandConsistency";
|
||||
|
||||
// ─── Section 1: Branding Guide ───
|
||||
|
||||
export interface ColorSwatch {
|
||||
name: string;
|
||||
hex: string;
|
||||
usage: string;
|
||||
}
|
||||
|
||||
export interface FontSpec {
|
||||
family: string;
|
||||
weight: string;
|
||||
usage: string;
|
||||
sampleText: string;
|
||||
}
|
||||
|
||||
export interface LogoUsageRule {
|
||||
rule: string;
|
||||
description: string;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface ToneOfVoice {
|
||||
personality: string[];
|
||||
communicationStyle: string;
|
||||
doExamples: string[];
|
||||
dontExamples: string[];
|
||||
}
|
||||
|
||||
export interface ChannelBrandingRule {
|
||||
channel: string;
|
||||
icon: string;
|
||||
profilePhoto: string;
|
||||
bannerSpec: string;
|
||||
bioTemplate: string;
|
||||
currentStatus: 'correct' | 'incorrect' | 'missing';
|
||||
}
|
||||
|
||||
export interface BrandGuide {
|
||||
colors: ColorSwatch[];
|
||||
fonts: FontSpec[];
|
||||
logoRules: LogoUsageRule[];
|
||||
toneOfVoice: ToneOfVoice;
|
||||
channelBranding: ChannelBrandingRule[];
|
||||
brandInconsistencies: BrandInconsistency[];
|
||||
}
|
||||
|
||||
// ─── Section 2: Channel Communication Strategy ───
|
||||
|
||||
export interface ChannelStrategyCard {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
icon: string;
|
||||
currentStatus: string;
|
||||
targetGoal: string;
|
||||
contentTypes: string[];
|
||||
postingFrequency: string;
|
||||
tone: string;
|
||||
formatGuidelines: string[];
|
||||
priority: 'P0' | 'P1' | 'P2';
|
||||
}
|
||||
|
||||
// ─── Section 3: Content Marketing Strategy ───
|
||||
|
||||
export interface ContentPillar {
|
||||
title: string;
|
||||
description: string;
|
||||
relatedUSP: string;
|
||||
exampleTopics: string[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ContentTypeRow {
|
||||
format: string;
|
||||
channels: string[];
|
||||
frequency: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
step: number;
|
||||
name: string;
|
||||
description: string;
|
||||
owner: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export interface RepurposingOutput {
|
||||
format: string;
|
||||
channel: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ContentStrategyData {
|
||||
pillars: ContentPillar[];
|
||||
typeMatrix: ContentTypeRow[];
|
||||
workflow: WorkflowStep[];
|
||||
repurposingSource: string;
|
||||
repurposingOutputs: RepurposingOutput[];
|
||||
}
|
||||
|
||||
// ─── Section 4: Content Calendar ───
|
||||
|
||||
export type ContentCategory = 'video' | 'blog' | 'social' | 'ad';
|
||||
|
||||
export interface CalendarEntry {
|
||||
dayOfWeek: number;
|
||||
channel: string;
|
||||
channelIcon: string;
|
||||
contentType: ContentCategory;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CalendarWeek {
|
||||
weekNumber: number;
|
||||
label: string;
|
||||
entries: CalendarEntry[];
|
||||
}
|
||||
|
||||
export interface ContentCountSummary {
|
||||
type: ContentCategory;
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface CalendarData {
|
||||
weeks: CalendarWeek[];
|
||||
monthlySummary: ContentCountSummary[];
|
||||
}
|
||||
|
||||
// ─── Section 5: Asset Collection ───
|
||||
|
||||
export type AssetSource = 'homepage' | 'naver_place' | 'blog' | 'social' | 'youtube';
|
||||
export type AssetType = 'photo' | 'video' | 'text';
|
||||
export type AssetStatus = 'collected' | 'pending' | 'needs_creation';
|
||||
|
||||
export interface AssetCard {
|
||||
id: string;
|
||||
source: AssetSource;
|
||||
sourceLabel: string;
|
||||
type: AssetType;
|
||||
title: string;
|
||||
description: string;
|
||||
repurposingSuggestions: string[];
|
||||
status: AssetStatus;
|
||||
}
|
||||
|
||||
export interface YouTubeRepurposeItem {
|
||||
title: string;
|
||||
views: number;
|
||||
type: 'Short' | 'Long';
|
||||
repurposeAs: string[];
|
||||
}
|
||||
|
||||
export interface AssetCollectionData {
|
||||
assets: AssetCard[];
|
||||
youtubeRepurpose: YouTubeRepurposeItem[];
|
||||
}
|
||||
|
||||
// ─── Root Plan Type ───
|
||||
|
||||
export interface MarketingPlan {
|
||||
id: string;
|
||||
reportId: string;
|
||||
clinicName: string;
|
||||
clinicNameEn: string;
|
||||
createdAt: string;
|
||||
targetUrl: string;
|
||||
brandGuide: BrandGuide;
|
||||
channelStrategies: ChannelStrategyCard[];
|
||||
contentStrategy: ContentStrategyData;
|
||||
calendar: CalendarData;
|
||||
assetCollection: AssetCollectionData;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { AssetCollectionPanel } from "@/features/plan/ui/assetCollection/AssetCollectionPanel";
|
||||
|
||||
export function PlanAssetCollectionSection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
id="asset-collection"
|
||||
title="에셋 수집"
|
||||
subtitle="에셋 수집 & 리퍼포징 소스"
|
||||
animateEnter={false}
|
||||
>
|
||||
<AssetCollectionPanel data={data.assetCollection} />
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { BrandingGuidePanel } from "@/features/plan/ui/branding/BrandingGuidePanel";
|
||||
|
||||
export function PlanBrandingGuideSection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
id="branding-guide"
|
||||
title="브랜딩 가이드"
|
||||
subtitle="브랜딩 가이드 빌드"
|
||||
animateEnter={false}
|
||||
>
|
||||
<BrandingGuidePanel data={data.brandGuide} />
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { ChannelStrategyGrid } from "@/features/plan/ui/channelStrategy/ChannelStrategyGrid";
|
||||
|
||||
export function PlanChannelStrategySection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
id="channel-strategy"
|
||||
title="채널 전략"
|
||||
subtitle="채널별 커뮤니케이션 전략"
|
||||
dark
|
||||
animateEnter={false}
|
||||
>
|
||||
<ChannelStrategyGrid channels={data.channelStrategies} />
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { ContentCalendarPanel } from "@/features/plan/ui/contentCalendar/ContentCalendarPanel";
|
||||
|
||||
export function PlanContentCalendarSection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
id="content-calendar"
|
||||
title="콘텐츠 캘린더"
|
||||
subtitle="콘텐츠 캘린더 (월간)"
|
||||
dark
|
||||
animateEnter={false}
|
||||
>
|
||||
<ContentCalendarPanel data={data.calendar} />
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { ContentStrategyPanel } from "@/features/plan/ui/contentStrategy/ContentStrategyPanel";
|
||||
|
||||
export function PlanContentStrategySection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection
|
||||
id="content-strategy"
|
||||
title="콘텐츠 전략"
|
||||
subtitle="콘텐츠 마케팅 전략"
|
||||
animateEnter={false}
|
||||
>
|
||||
<ContentStrategyPanel data={data.contentStrategy} />
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DownloadIcon from "@/assets/report/download.svg?react";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
|
||||
function PlanCtaRocketIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={28}
|
||||
height={28}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** `DEMO/src/components/plan/PlanCTA.tsx` 레이아웃·카피·버튼 구성과 동일 (PDF는 Phase 1 목 동작). */
|
||||
export function PlanCtaSection() {
|
||||
const navigate = useNavigate();
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const planId = data.id;
|
||||
|
||||
const exportPdf = () => {
|
||||
setIsExporting(true);
|
||||
window.setTimeout(() => setIsExporting(false), 900);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="py-16 md:py-20 px-6 scroll-mt-36" aria-label="다음 단계 안내">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div
|
||||
data-cta-card
|
||||
className="rounded-2xl bg-gradient-to-r from-[var(--color-marketing-cream)] via-[var(--color-marketing-lilac)] to-[var(--color-marketing-ice)] 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">
|
||||
<PlanCtaRocketIcon className="text-violet-700" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-serif text-2xl md:text-3xl font-bold text-navy-950 mb-3 break-keep">
|
||||
콘텐츠 제작을 시작하세요
|
||||
</h3>
|
||||
|
||||
<p className="text-navy-950/60 mb-8 max-w-lg mx-auto break-keep">
|
||||
INFINITH가 브랜딩부터 콘텐츠 제작, 채널 배포까지 자동화합니다.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => navigate(`/studio/${planId}`)}
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<span className="break-keep">콘텐츠 제작 시작</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={exportPdf}
|
||||
disabled={isExporting}
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3 text-sm font-medium text-navy-950 shadow-sm hover:shadow-md disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<span
|
||||
className="w-4 h-4 border-2 border-navy-950 border-t-transparent rounded-full animate-spin shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<DownloadIcon width={16} height={16} className="shrink-0" aria-hidden />
|
||||
)}
|
||||
<span className="break-keep">플랜 다운로드</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { PlanHeaderDaysBadge } from "@/features/plan/ui/header/PlanHeaderDaysBadge";
|
||||
import { PlanHeaderHeroBlobs } from "@/features/plan/ui/header/PlanHeaderHeroBlobs";
|
||||
import { PlanHeaderHeroColumn } from "@/features/plan/ui/header/PlanHeaderHeroColumn";
|
||||
import { PLAN_HEADER_BG_CLASS } from "@/features/plan/ui/header/planHeaderSectionClasses";
|
||||
|
||||
export function PlanHeaderSection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="마케팅 플랜 헤더"
|
||||
className={`relative overflow-hidden scroll-mt-36 py-20 md:py-28 px-6 ${PLAN_HEADER_BG_CLASS}`}
|
||||
>
|
||||
<PlanHeaderHeroBlobs />
|
||||
|
||||
<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">
|
||||
<PlanHeaderHeroColumn
|
||||
clinicName={data.clinicName}
|
||||
clinicNameEn={data.clinicNameEn}
|
||||
date={data.createdAt}
|
||||
targetUrl={data.targetUrl}
|
||||
/>
|
||||
<PlanHeaderDaysBadge />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { PageSection } from "@/components/section/PageSection";
|
||||
import { useCurrentMarketingPlan } from "@/features/plan/hooks/useCurrentMarketingPlan";
|
||||
import { MyAssetUploadPanel } from "@/features/plan/ui/myAssets/MyAssetUploadPanel";
|
||||
|
||||
export function PlanMyAssetUploadSection() {
|
||||
const { data, error } = useCurrentMarketingPlan();
|
||||
|
||||
if (error || !data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-no-print>
|
||||
<PageSection id="my-asset-upload" title="My Assets" subtitle="나의 에셋 업로드" animateEnter={false}>
|
||||
<MyAssetUploadPanel />
|
||||
</PageSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { Button } from "@/components/atoms/Button";
|
||||
|
||||
export type SegmentTabButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
"type" | "className"
|
||||
> & {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
/** 비활성 탭에 `card-shadow` (에셋 업로드 필터 등) */
|
||||
inactiveShadow?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SegmentTabButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
inactiveShadow = false,
|
||||
className = "",
|
||||
...rest
|
||||
}: SegmentTabButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant={active ? "primary" : "outline"}
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"px-4 py-2 body-14-medium",
|
||||
!active && inactiveShadow ? "card-shadow" : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
|
||||
import { Pill } from "@/components/atoms/Pill";
|
||||
import { Surface } from "@/components/atoms/Surface";
|
||||
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||
import type { AssetCollectionData } from "@/features/plan/types/marketingPlan";
|
||||
import {
|
||||
ASSET_COLLECTION_FILTER_TABS,
|
||||
type AssetCollectionFilterKey,
|
||||
} from "@/features/plan/ui/assetCollection/assetCollectionFilterTabs";
|
||||
import {
|
||||
assetSourceBadgeClass,
|
||||
assetStatusConfig,
|
||||
assetTypeBadgeClass,
|
||||
assetTypeDisplayLabel,
|
||||
formatYoutubeViews,
|
||||
} from "@/features/plan/ui/assetCollection/assetCollectionBadgeClasses";
|
||||
|
||||
type AssetCollectionPanelProps = {
|
||||
data: AssetCollectionData;
|
||||
};
|
||||
|
||||
export function AssetCollectionPanel({ data }: AssetCollectionPanelProps) {
|
||||
const [activeFilter, setActiveFilter] = useState<AssetCollectionFilterKey>("all");
|
||||
|
||||
const filteredAssets =
|
||||
activeFilter === "all" ? data.assets : data.assets.filter((a) => a.source === activeFilter);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="에셋 출처 필터">
|
||||
{ASSET_COLLECTION_FILTER_TABS.map((tab) => {
|
||||
const isActive = activeFilter === tab.key;
|
||||
return (
|
||||
<SegmentTabButton
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
active={isActive}
|
||||
onClick={() => setActiveFilter(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</SegmentTabButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||
{filteredAssets.map((asset) => {
|
||||
const statusInfo = assetStatusConfig(asset.status);
|
||||
return (
|
||||
<Surface key={asset.id}>
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<Pill className={`shrink-0 ${assetSourceBadgeClass(asset.source)}`}>{asset.sourceLabel}</Pill>
|
||||
<Pill className={`shrink-0 ${assetTypeBadgeClass(asset.type)}`}>{assetTypeDisplayLabel(asset.type)}</Pill>
|
||||
<Pill className={`ml-auto shrink-0 ${statusInfo.className}`}>{statusInfo.label}</Pill>
|
||||
</div>
|
||||
|
||||
<h4 className="title-14 text-navy-900 mb-1 break-keep">{asset.title}</h4>
|
||||
<p className="body-14 text-neutral-70 mb-3 break-keep">{asset.description}</p>
|
||||
|
||||
{asset.repurposingSuggestions.length > 0 ? (
|
||||
<div>
|
||||
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">
|
||||
Repurposing →
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{asset.repurposingSuggestions.map((suggestion, j) => (
|
||||
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
|
||||
{suggestion}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.youtubeRepurpose.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="font-serif headline-24 text-navy-900 mb-4 break-keep">
|
||||
YouTube Top Videos for Repurposing
|
||||
</h3>
|
||||
<div className="flex overflow-x-auto gap-4 pb-4 scrollbar-hide">
|
||||
{data.youtubeRepurpose.map((video) => (
|
||||
<Surface key={video.title} className="min-w-[280px] shrink-0">
|
||||
<div className="flex items-start gap-2 mb-3 min-w-0">
|
||||
<ChannelYoutubeIcon width={18} height={18} className="text-[var(--color-status-critical-dot)] shrink-0 mt-1" aria-hidden />
|
||||
<h4 className="title-14 text-navy-900 break-keep min-w-0">{video.title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<Pill className="shrink-0 bg-neutral-10 text-neutral-80">
|
||||
{formatYoutubeViews(video.views)} views
|
||||
</Pill>
|
||||
<Pill
|
||||
className={
|
||||
video.type === "Short"
|
||||
? "shrink-0 bg-lavender-100 text-violet-700 border border-lavender-300"
|
||||
: "shrink-0 bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]"
|
||||
}
|
||||
>
|
||||
{video.type}
|
||||
</Pill>
|
||||
</div>
|
||||
<p className="label-12 font-semibold text-neutral-60 uppercase mb-2 break-keep">Repurpose As:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{video.repurposeAs.map((suggestion, j) => (
|
||||
<Pill key={j} size="sm" className="shrink-0 bg-lavender-100 text-violet-700">
|
||||
{suggestion}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import type { AssetSource, AssetStatus, AssetType } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
export function assetSourceBadgeClass(source: AssetSource): string {
|
||||
switch (source) {
|
||||
case "homepage":
|
||||
return "bg-neutral-10 text-neutral-80 border border-neutral-20";
|
||||
case "naver_place":
|
||||
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border border-[var(--color-status-good-border)]";
|
||||
case "blog":
|
||||
return "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]";
|
||||
case "social":
|
||||
return "bg-[var(--color-marketing-blush)]/50 text-navy-800 border border-neutral-30";
|
||||
case "youtube":
|
||||
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border border-[var(--color-status-critical-border)]";
|
||||
default:
|
||||
return "bg-neutral-10 text-neutral-70 border border-neutral-20";
|
||||
}
|
||||
}
|
||||
|
||||
export function assetTypeDisplayLabel(type: AssetType): string {
|
||||
switch (type) {
|
||||
case "photo":
|
||||
return "사진";
|
||||
case "video":
|
||||
return "영상";
|
||||
case "text":
|
||||
return "텍스트";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
export function assetTypeBadgeClass(type: AssetType): string {
|
||||
switch (type) {
|
||||
case "photo":
|
||||
return "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border border-[var(--color-status-info-border)]";
|
||||
case "video":
|
||||
return "bg-lavender-100 text-violet-700 border border-lavender-300";
|
||||
case "text":
|
||||
return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border border-[var(--color-status-warning-border)]";
|
||||
default:
|
||||
return "bg-neutral-10 text-neutral-70 border border-neutral-20";
|
||||
}
|
||||
}
|
||||
|
||||
export function assetStatusConfig(status: AssetStatus): { className: string; label: string } {
|
||||
switch (status) {
|
||||
case "collected":
|
||||
return {
|
||||
className:
|
||||
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border border-[var(--color-status-good-border)]",
|
||||
label: "수집완료",
|
||||
};
|
||||
case "pending":
|
||||
return {
|
||||
className:
|
||||
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border border-[var(--color-status-warning-border)]",
|
||||
label: "수집 대기",
|
||||
};
|
||||
case "needs_creation":
|
||||
return {
|
||||
className:
|
||||
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border border-[var(--color-status-critical-border)]",
|
||||
label: "제작 필요",
|
||||
};
|
||||
default:
|
||||
return { className: "bg-neutral-10 text-neutral-70 border border-neutral-20", label: status };
|
||||
}
|
||||
}
|
||||
|
||||
export function formatYoutubeViews(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);
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
export const ASSET_COLLECTION_FILTER_TABS = [
|
||||
{ key: "all", label: "전체" },
|
||||
{ key: "homepage", label: "홈페이지" },
|
||||
{ key: "naver_place", label: "네이버" },
|
||||
{ key: "blog", label: "블로그" },
|
||||
{ key: "social", label: "소셜미디어" },
|
||||
{ key: "youtube", label: "YouTube" },
|
||||
] as const;
|
||||
|
||||
export type AssetCollectionFilterKey = (typeof ASSET_COLLECTION_FILTER_TABS)[number]["key"];
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { ComponentType, SVGProps } from "react";
|
||||
import ChannelFacebookIcon from "@/assets/icons/channel-facebook.svg?react";
|
||||
import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react";
|
||||
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
|
||||
import GlobeIcon from "@/assets/report/globe.svg?react";
|
||||
import MessageCircleIcon from "@/assets/report/message-circle.svg?react";
|
||||
import VideoIcon from "@/assets/icons/video.svg?react";
|
||||
|
||||
type SvgIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
const CHANNEL_ICON_MAP: Record<string, SvgIcon> = {
|
||||
youtube: ChannelYoutubeIcon,
|
||||
instagram: ChannelInstagramIcon,
|
||||
facebook: ChannelFacebookIcon,
|
||||
globe: GlobeIcon,
|
||||
video: VideoIcon,
|
||||
messagesquare: MessageCircleIcon,
|
||||
};
|
||||
|
||||
function normalizeIconKey(icon: string): string {
|
||||
return icon.toLowerCase().replace(/-/g, "");
|
||||
}
|
||||
|
||||
type BrandingChannelIconProps = {
|
||||
icon: string;
|
||||
className?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export function BrandingChannelIcon({ icon, className, width = 18, height = 18 }: BrandingChannelIconProps) {
|
||||
const key = normalizeIconKey(icon);
|
||||
const Icon = CHANNEL_ICON_MAP[key] ?? GlobeIcon;
|
||||
return <Icon className={className} width={width} height={height} aria-hidden />;
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { Surface } from "@/components/atoms/Surface";
|
||||
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
|
||||
import {
|
||||
brandingChannelStatusBadgeClass,
|
||||
brandingChannelStatusLabel,
|
||||
} from "@/features/plan/ui/branding/brandingChannelStatusClasses";
|
||||
|
||||
type BrandingChannelRulesTabProps = {
|
||||
channels: BrandGuide["channelBranding"];
|
||||
};
|
||||
|
||||
export function BrandingChannelRulesTab({ channels }: BrandingChannelRulesTabProps) {
|
||||
return (
|
||||
<div className="animate-fade-in-up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{channels.map((ch) => (
|
||||
<Surface key={ch.channel}>
|
||||
<div className="flex items-center gap-3 mb-4 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-violet-500/10 to-violet-700/10 flex items-center justify-center shrink-0">
|
||||
<BrandingChannelIcon icon={ch.icon} className="text-violet-500" />
|
||||
</div>
|
||||
<p className="font-bold text-navy-900 break-keep truncate min-w-0">{ch.channel}</p>
|
||||
<span
|
||||
className={`ml-auto text-xs font-medium px-3 py-1 rounded-full border shrink-0 break-keep ${brandingChannelStatusBadgeClass(ch.currentStatus)}`}
|
||||
>
|
||||
{brandingChannelStatusLabel(ch.currentStatus)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 body-14">
|
||||
<div>
|
||||
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Profile Photo</p>
|
||||
<p className="text-neutral-80 font-medium break-keep">{ch.profilePhoto}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Banner Spec</p>
|
||||
<p className="text-neutral-80 font-medium break-keep">{ch.bannerSpec}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-neutral-60 label-12 uppercase tracking-wide mb-1 break-keep">Bio Template</p>
|
||||
<div className="bg-neutral-10 rounded-xl p-3 border border-neutral-20">
|
||||
<p className="font-mono label-12 text-neutral-70 whitespace-pre-wrap break-keep">{ch.bioTemplate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { BrandConsistencyMap } from "@/components/brand/BrandConsistencyMap";
|
||||
import { SegmentTabButton } from "@/features/plan/ui/SegmentTabButton";
|
||||
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||
import { BRANDING_GUIDE_TAB_ITEMS, type BrandingGuideTabKey } from "@/features/plan/ui/branding/brandingTabs";
|
||||
import { BrandingChannelRulesTab } from "@/features/plan/ui/branding/BrandingChannelRulesTab";
|
||||
import { BrandingToneVoiceTab } from "@/features/plan/ui/branding/BrandingToneVoiceTab";
|
||||
import { BrandingVisualIdentityTab } from "@/features/plan/ui/branding/BrandingVisualIdentityTab";
|
||||
|
||||
type BrandingGuidePanelProps = {
|
||||
data: BrandGuide;
|
||||
};
|
||||
|
||||
export function BrandingGuidePanel({ data }: BrandingGuidePanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<BrandingGuideTabKey>("visual");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2 mb-8" role="tablist" aria-label="브랜딩 가이드 탭">
|
||||
{BRANDING_GUIDE_TAB_ITEMS.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
return (
|
||||
<SegmentTabButton
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
title={tab.labelKr}
|
||||
active={isActive}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</SegmentTabButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTab === "visual" ? <BrandingVisualIdentityTab data={data} /> : null}
|
||||
{activeTab === "tone" ? <BrandingToneVoiceTab tone={data.toneOfVoice} /> : null}
|
||||
{activeTab === "channels" ? <BrandingChannelRulesTab channels={data.channelBranding} /> : null}
|
||||
{activeTab === "consistency" ? (
|
||||
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} showHeading={false} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
type BrandingToneVoiceTabProps = {
|
||||
tone: BrandGuide["toneOfVoice"];
|
||||
};
|
||||
|
||||
export function BrandingToneVoiceTab({ tone }: BrandingToneVoiceTabProps) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in-up">
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Personality</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tone.personality.map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="bg-gradient-to-r from-violet-700/10 to-navy-950/10 text-violet-700 border border-lavender-300 rounded-full px-4 py-2 body-14-medium break-keep"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Communication Style</h3>
|
||||
<div className="rounded-2xl bg-neutral-10 p-6 border border-neutral-20">
|
||||
<p className="body-16 text-neutral-80 leading-relaxed break-keep">{tone.communicationStyle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="title-14 text-[var(--color-status-good-text)] mb-3 flex items-center gap-2 break-keep">
|
||||
<CheckCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-good-dot)]" aria-hidden />
|
||||
DO
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{tone.doExamples.map((example, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l-4 border-[var(--color-status-good-dot)] bg-[var(--color-status-good-bg)]/30 p-4 rounded-r-xl"
|
||||
>
|
||||
<p className="body-14 text-neutral-80 break-keep">{example}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="title-14 text-[var(--color-status-critical-text)] mb-3 flex items-center gap-2 break-keep">
|
||||
<XCircleIcon width={16} height={16} className="shrink-0 text-[var(--color-status-critical-dot)]" aria-hidden />
|
||||
DON'T
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{tone.dontExamples.map((example, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-l-4 border-[var(--color-status-critical-dot)] bg-[var(--color-status-critical-bg)]/30 p-4 rounded-r-xl"
|
||||
>
|
||||
<p className="body-14 text-neutral-80 break-keep">{example}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||
import { Surface } from "@/components/atoms/Surface";
|
||||
import type { BrandGuide } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
type BrandingVisualIdentityTabProps = {
|
||||
data: BrandGuide;
|
||||
};
|
||||
|
||||
export function BrandingVisualIdentityTab({ data }: BrandingVisualIdentityTabProps) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in-up">
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Color Palette</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{data.colors.map((swatch, i) => (
|
||||
<Surface key={`${swatch.name}-${swatch.hex}-${i}`} padding="none" overflowHidden>
|
||||
<div className="h-20" style={{ backgroundColor: swatch.hex }} />
|
||||
<div className="p-3">
|
||||
<p className="font-mono body-14 text-neutral-80">{swatch.hex}</p>
|
||||
<p className="title-14 text-navy-900 break-keep">{swatch.name}</p>
|
||||
<p className="label-12 text-neutral-60 break-keep">{swatch.usage}</p>
|
||||
</div>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Typography</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{data.fonts.map((spec) => (
|
||||
<Surface key={`${spec.family}-${spec.weight}`}>
|
||||
<p className="label-12 text-neutral-60 uppercase tracking-wide mb-2 break-keep">{spec.family}</p>
|
||||
<p
|
||||
className={`mb-3 text-navy-900 break-keep ${
|
||||
spec.weight.toLowerCase().includes("bold") ? "text-2xl font-bold" : "text-lg"
|
||||
}`}
|
||||
style={{ fontFamily: spec.family }}
|
||||
>
|
||||
{spec.sampleText}
|
||||
</p>
|
||||
<p className="label-12 text-neutral-60 break-keep">
|
||||
<span className="font-medium text-neutral-80">{spec.weight}</span>
|
||||
{" · "}
|
||||
{spec.usage}
|
||||
</p>
|
||||
</Surface>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-serif font-bold text-2xl text-navy-900 mb-4 break-keep">Logo Rules</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.logoRules.map((rule) => (
|
||||
<div
|
||||
key={rule.rule}
|
||||
className={`rounded-2xl p-5 ${
|
||||
rule.correct
|
||||
? "border-2 border-[var(--color-status-good-border)] bg-[var(--color-status-good-bg)]/30"
|
||||
: "border-2 border-[var(--color-status-critical-border)] bg-[var(--color-status-critical-bg)]/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{rule.correct ? (
|
||||
<CheckCircleIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-[var(--color-status-good-dot)] shrink-0 mt-1"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<XCircleIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className="text-[var(--color-status-critical-dot)] shrink-0 mt-1"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="title-14 text-navy-900 break-keep">{rule.rule}</p>
|
||||
<p className="body-14 text-neutral-70 mt-1 break-keep">{rule.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { ChannelBrandingRule } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
export function brandingChannelStatusBadgeClass(status: ChannelBrandingRule["currentStatus"]): string {
|
||||
switch (status) {
|
||||
case "correct":
|
||||
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]";
|
||||
case "incorrect":
|
||||
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]";
|
||||
case "missing":
|
||||
return "bg-neutral-10 text-neutral-60 border-neutral-30";
|
||||
default:
|
||||
return "bg-neutral-10 text-neutral-60 border-neutral-30";
|
||||
}
|
||||
}
|
||||
|
||||
export function brandingChannelStatusLabel(status: ChannelBrandingRule["currentStatus"]): string {
|
||||
switch (status) {
|
||||
case "correct":
|
||||
return "Correct";
|
||||
case "incorrect":
|
||||
return "Incorrect";
|
||||
case "missing":
|
||||
return "Missing";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export const BRANDING_GUIDE_TAB_ITEMS = [
|
||||
{ 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 BrandingGuideTabKey = (typeof BRANDING_GUIDE_TAB_ITEMS)[number]["key"];
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
import CalendarIcon from "@/assets/report/calendar.svg?react";
|
||||
import { Pill } from "@/components/atoms/Pill";
|
||||
import { Surface } from "@/components/atoms/Surface";
|
||||
import type { ChannelStrategyCard } from "@/features/plan/types/marketingPlan";
|
||||
import { BrandingChannelIcon } from "@/features/plan/ui/branding/BrandingChannelIcon";
|
||||
import { channelStrategyPriorityPillClass } from "@/features/plan/ui/channelStrategy/channelStrategyPillClasses";
|
||||
|
||||
type ChannelStrategyGridProps = {
|
||||
channels: ChannelStrategyCard[];
|
||||
};
|
||||
|
||||
function ChannelStrategyCard({ ch, index }: { ch: ChannelStrategyCard; index: number }) {
|
||||
const delayClass =
|
||||
index === 0
|
||||
? ""
|
||||
: index === 1
|
||||
? "animation-delay-100"
|
||||
: index === 2
|
||||
? "animation-delay-200"
|
||||
: "animation-delay-300";
|
||||
|
||||
return (
|
||||
<Surface
|
||||
bordered={false}
|
||||
padding="lg"
|
||||
interactive="lift"
|
||||
className={`animate-fade-in-up ${delayClass}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4 min-w-0">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--color-status-good-bg)] flex items-center justify-center shrink-0">
|
||||
<BrandingChannelIcon icon={ch.icon} className="text-[var(--color-status-good-dot)]" width={20} height={20} />
|
||||
</div>
|
||||
<h4 className="title-18-md-20 text-navy-900 flex-1 min-w-0 break-keep">{ch.channelName}</h4>
|
||||
<Pill weight="semibold" className={`shrink-0 border ${channelStrategyPriorityPillClass(ch.priority)}`}>
|
||||
{ch.priority}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
<Pill className="border bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]">
|
||||
{ch.currentStatus}
|
||||
</Pill>
|
||||
<span className="body-14 text-neutral-60 shrink-0" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<Pill className="border bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]">
|
||||
{ch.targetGoal}
|
||||
</Pill>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{ch.contentTypes.map((type) => (
|
||||
<Pill key={type} className="bg-neutral-10 border border-neutral-20 text-neutral-70">
|
||||
{type}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-3 min-w-0">
|
||||
<CalendarIcon width={14} height={14} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
||||
<p className="body-14 text-neutral-70 break-keep">{ch.postingFrequency}</p>
|
||||
</div>
|
||||
|
||||
<p className="body-14 italic text-violet-500/80 mb-4 break-keep">{ch.tone}</p>
|
||||
|
||||
<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-violet-500 mt-2" aria-hidden />
|
||||
<span className="body-14 text-neutral-80 break-keep">{guideline}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelStrategyGrid({ channels }: ChannelStrategyGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{channels.map((ch, index) => (
|
||||
<ChannelStrategyCard key={ch.channelId} ch={ch} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import type { ChannelStrategyCard } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
export function channelStrategyPriorityPillClass(priority: ChannelStrategyCard["priority"]): string {
|
||||
switch (priority) {
|
||||
case "P0":
|
||||
return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]";
|
||||
case "P1":
|
||||
return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]";
|
||||
case "P2":
|
||||
return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]";
|
||||
default:
|
||||
return "bg-neutral-10 text-neutral-60 border-neutral-20";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
import { Pill } from "@/components/atoms/Pill";
|
||||
import { Surface } from "@/components/atoms/Surface";
|
||||
import type { CalendarData, CalendarEntry, ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||
import {
|
||||
CALENDAR_CONTENT_TYPE_LABELS,
|
||||
CALENDAR_DAY_HEADERS,
|
||||
calendarContentTypeVisual,
|
||||
} from "@/features/plan/ui/contentCalendar/calendarContentTypeClasses";
|
||||
import { ContentCalendarTypeIcon } from "@/features/plan/ui/contentCalendar/ContentCalendarTypeIcon";
|
||||
|
||||
type ContentCalendarPanelProps = {
|
||||
data: CalendarData;
|
||||
};
|
||||
|
||||
function buildDayCells(entries: CalendarEntry[]): CalendarEntry[][] {
|
||||
const dayCells: CalendarEntry[][] = Array.from({ length: 7 }, () => []);
|
||||
for (const entry of entries) {
|
||||
const dayIndex = entry.dayOfWeek;
|
||||
if (dayIndex >= 0 && dayIndex <= 6) {
|
||||
dayCells[dayIndex].push(entry);
|
||||
}
|
||||
}
|
||||
return dayCells;
|
||||
}
|
||||
|
||||
export function ContentCalendarPanel({ data }: ContentCalendarPanelProps) {
|
||||
const legendTypes = Object.keys(CALENDAR_CONTENT_TYPE_LABELS) as ContentCategory[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-4 mb-8">
|
||||
{data.monthlySummary.map((item) => {
|
||||
const v = calendarContentTypeVisual(item.type);
|
||||
return (
|
||||
<Surface
|
||||
key={item.type}
|
||||
padding="sm"
|
||||
surface="none"
|
||||
className={`flex-1 min-w-[140px] ${v.summaryBg} ${v.summaryBorder}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: item.color }} aria-hidden />
|
||||
<span className={`body-14-medium break-keep ${v.summaryText}`}>{item.label}</span>
|
||||
</div>
|
||||
<span className={`text-2xl font-bold break-keep ${v.summaryText}`}>{item.count}</span>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data.weeks.map((week) => {
|
||||
const dayCells = buildDayCells(week.entries);
|
||||
return (
|
||||
<Surface key={week.weekNumber} className="mb-4">
|
||||
<p className="title-14 text-navy-900 mb-3 break-keep">{week.label}</p>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{CALENDAR_DAY_HEADERS.map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="label-12 text-neutral-60 uppercase tracking-wide text-center mb-1 font-medium break-keep"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
{dayCells.map((entries, dayIdx) => (
|
||||
<div
|
||||
key={dayIdx}
|
||||
className={`min-h-[80px] rounded-xl p-2 ${
|
||||
entries.length > 0
|
||||
? "bg-neutral-10/80 border border-neutral-20"
|
||||
: "border border-dashed border-neutral-30/80"
|
||||
}`}
|
||||
>
|
||||
{entries.map((entry, entryIdx) => {
|
||||
const v = calendarContentTypeVisual(entry.contentType);
|
||||
return (
|
||||
<div
|
||||
key={`${entry.title}-${entryIdx}`}
|
||||
className={`${v.entryBg} border ${v.entryBorder} rounded-lg p-2 mb-1 card-shadow`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<ContentCalendarTypeIcon type={entry.contentType} className={v.entryText} />
|
||||
</div>
|
||||
<p className="body-14 text-neutral-80 leading-tight break-keep">{entry.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap gap-3 mt-4">
|
||||
{legendTypes.map((type) => {
|
||||
const v = calendarContentTypeVisual(type);
|
||||
return (
|
||||
<Pill
|
||||
key={type}
|
||||
className={`${v.summaryBg} ${v.summaryText} border ${v.summaryBorder} card-shadow`}
|
||||
>
|
||||
{CALENDAR_CONTENT_TYPE_LABELS[type]}
|
||||
</Pill>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import type { ComponentType, SVGProps } from "react";
|
||||
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
|
||||
import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
|
||||
import VideoIcon from "@/assets/icons/video.svg?react";
|
||||
import Link2Icon from "@/assets/report/link-2.svg?react";
|
||||
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
type Svg = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
|
||||
const MAP: Record<ContentCategory, Svg> = {
|
||||
video: VideoIcon,
|
||||
blog: Link2Icon,
|
||||
social: ExternalLinkIcon,
|
||||
ad: TrendingUpIcon,
|
||||
};
|
||||
|
||||
type ContentCalendarTypeIconProps = {
|
||||
type: ContentCategory;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ContentCalendarTypeIcon({ type, className }: ContentCalendarTypeIconProps) {
|
||||
const Icon = MAP[type];
|
||||
return <Icon width={11} height={11} className={className} aria-hidden />;
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import type { ContentCategory } from "@/features/plan/types/marketingPlan";
|
||||
|
||||
export type CalendarTypeVisual = {
|
||||
summaryBg: string;
|
||||
summaryBorder: string;
|
||||
summaryText: string;
|
||||
entryBg: string;
|
||||
entryBorder: string;
|
||||
entryText: string;
|
||||
};
|
||||
|
||||
export function calendarContentTypeVisual(type: ContentCategory): CalendarTypeVisual {
|
||||
switch (type) {
|
||||
case "video":
|
||||
return {
|
||||
summaryBg: "bg-[var(--color-status-good-bg)]",
|
||||
summaryBorder: "border-[var(--color-status-good-border)]",
|
||||
summaryText: "text-[var(--color-status-good-text)]",
|
||||
entryBg: "bg-[var(--color-status-good-bg)]",
|
||||
entryBorder: "border-[var(--color-status-good-border)]",
|
||||
entryText: "text-[var(--color-status-good-text)]",
|
||||
};
|
||||
case "blog":
|
||||
return {
|
||||
summaryBg: "bg-[var(--color-status-info-bg)]",
|
||||
summaryBorder: "border-[var(--color-status-info-border)]",
|
||||
summaryText: "text-[var(--color-status-info-text)]",
|
||||
entryBg: "bg-[var(--color-status-info-bg)]",
|
||||
entryBorder: "border-[var(--color-status-info-border)]",
|
||||
entryText: "text-[var(--color-status-info-text)]",
|
||||
};
|
||||
case "social":
|
||||
return {
|
||||
summaryBg: "bg-[var(--color-status-warning-bg)]",
|
||||
summaryBorder: "border-[var(--color-status-warning-border)]",
|
||||
summaryText: "text-[var(--color-status-warning-text)]",
|
||||
entryBg: "bg-[var(--color-status-warning-bg)]",
|
||||
entryBorder: "border-[var(--color-status-warning-border)]",
|
||||
entryText: "text-[var(--color-status-warning-text)]",
|
||||
};
|
||||
case "ad":
|
||||
return {
|
||||
summaryBg: "bg-[var(--color-status-critical-bg)]",
|
||||
summaryBorder: "border-[var(--color-status-critical-border)]",
|
||||
summaryText: "text-[var(--color-status-critical-text)]",
|
||||
entryBg: "bg-[var(--color-status-critical-bg)]",
|
||||
entryBorder: "border-[var(--color-status-critical-border)]",
|
||||
entryText: "text-[var(--color-status-critical-text)]",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
summaryBg: "bg-neutral-10",
|
||||
summaryBorder: "border-neutral-20",
|
||||
summaryText: "text-neutral-80",
|
||||
entryBg: "bg-neutral-10",
|
||||
entryBorder: "border-neutral-20",
|
||||
entryText: "text-neutral-80",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CALENDAR_CONTENT_TYPE_LABELS: Record<ContentCategory, string> = {
|
||||
video: "Video",
|
||||
blog: "Blog",
|
||||
social: "Social",
|
||||
ad: "Ad",
|
||||
};
|
||||
|
||||
export const CALENDAR_DAY_HEADERS = ["월", "화", "수", "목", "금", "토", "일"] as const;
|
||||