Compare commits

..

9 Commits

197 changed files with 6346 additions and 38 deletions

View File

@ -10,25 +10,36 @@
## 디렉토리 구조
디렉토리 구조는 다음을 따를 예정입니다.
```bash
src/
├── app/ # 애플리케이션 진입점 및 전역 설정 (Router, Providers, 글로벌 스타일)
├── assets/ # 정적 파일 (이미지, 폰트, 로티 애니메이션 등)
├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 모달, 디자인 시스템)
│ └── providers/ # React Context Provider 모음 (QueryProvider 등)
├── assets/ # 정적 파일 (이미지, 폰트, SVG 아이콘 등)
├── components/ # 도메인에 종속되지 않는 공통 UI 컴포넌트 (버튼, 카드, 디자인 시스템)
├── features/ # 핵심 비즈니스 로직 및 도메인 영역 (이 구조의 핵심)
│ ├── auth/ # 특정 도메인 (예: 인증)
│ │ ├── api/ # 해당 도메인 전용 API 통신 함수
│ ├── home/ # 홈(랜딩) 도메인
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
│ │ ├── store/ # 해당 도메인 전용 상태 (Zustand 등)
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
│ ├── report/ # 리포트 도메인
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
│ │ ├── mocks/ # API 연동 전 임시 목업 데이터
│ │ ├── types/ # 해당 도메인 전용 타입 정의
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
├── hooks/ # 전역에서 사용하는 공통 훅 (useClickOutside 등)
├── layouts/ # 페이지 레이아웃 (GNB, Sidebar, 풋터 등)
├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (여기서는 features의 컴포넌트만 조립)
│ └── plan/ # 마케팅 플랜 도메인 (report와 동일한 구조)
│ ├── config/
│ ├── hooks/
│ ├── mocks/
│ ├── types/
│ └── ui/
├── hooks/ # 전역에서 사용하는 공통 훅 (useInView 등)
├── layouts/ # 페이지 레이아웃 (GNB, SubNav, Footer 등)
├── pages/ # 라우팅과 1:1 매칭되는 페이지 진입점 (features의 컴포넌트만 조립)
├── services/ # 공통 API 클라이언트 설정 (Axios 인스턴스, 인터셉터 등)
├── store/ # 전역 상태 관리 (사용자 세션, 테마 등)
└── utils/ # 공통 유틸리티 함수 (날짜 포맷팅, 정규식 등)
├── types/ # 여러 도메인에서 공유하는 공통 타입 정의
└── utils/ # 공통 유틸리티 함수 (숫자 포맷팅, URL 처리 등)
```
## 시작하기

View File

@ -19,5 +19,14 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
])

View File

@ -1,9 +1,12 @@
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() {
@ -12,6 +15,10 @@ 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>
)
}

View File

@ -130,12 +130,27 @@
/* ─── Utility Classes ─────────────────────────────────────────────── */
/* 라이트 섹션 헤딩 그라디언트 텍스트 */
/*
- base h2 { color: navy } color
- background clip background-image */
.text-gradient {
background: linear-gradient(to right, var(--color-navy-900), #3b5998);
color: transparent;
background-image: linear-gradient(to right, var(--color-navy-900), #3b5998);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
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;
}
/* 반투명 글래스 카드 — 랜딩 섹션 */
@ -150,7 +165,7 @@
/* 브랜드 그라디언트 Primary 버튼 */
.btn-primary {
@apply bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
@apply cursor-pointer bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
}
/* ─── Typography Scale ────────────────────────────────────────────── */

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 226 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 423 B

View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 329 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 485 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 281 B

10
src/assets/icons/eye.svg Normal file
View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 189 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 511 B

View File

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 225 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 345 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 336 B

View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 429 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 238 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 280 B

View File

@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 454 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 301 B

View File

@ -0,0 +1,10 @@
<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>

After

Width:  |  Height:  |  Size: 347 B

View File

@ -0,0 +1,9 @@
<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>

After

Width:  |  Height:  |  Size: 282 B

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 293 B

View File

@ -0,0 +1,33 @@
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>
);
}

View File

@ -0,0 +1,37 @@
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>
);
}

View File

@ -0,0 +1,60 @@
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>
);
}

View File

@ -0,0 +1,4 @@
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";

View File

@ -0,0 +1,2 @@
/** 앱 전역에서 쓸 수 있는 브랜드 그라데이션 배경 클래스 (버튼·블록 배경 등) */
export const UI_PRIMARY_GRADIENT_CLASS = "bg-gradient-to-r from-violet-700 to-navy-950";

View File

@ -0,0 +1,45 @@
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>
);
}

View File

@ -0,0 +1,135 @@
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>
);
}

View File

@ -0,0 +1,47 @@
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>
);
}

View File

@ -0,0 +1,28 @@
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>
);
}

View File

@ -0,0 +1,39 @@
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>
);
}

View File

@ -0,0 +1,38 @@
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>
);
}

View File

@ -0,0 +1,47 @@
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>
);
}

View File

@ -0,0 +1,32 @@
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>
);
}

22
src/components/index.ts Normal file
View File

@ -0,0 +1,22 @@
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";

View File

@ -0,0 +1,34 @@
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>
);
}

View File

@ -0,0 +1,22 @@
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>
);
}

View File

@ -0,0 +1,87 @@
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>
);
}

View File

@ -0,0 +1,38 @@
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>
);
}

View File

@ -0,0 +1,73 @@
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>
);
}

View File

@ -5,7 +5,7 @@ import {
CTA_FOOTNOTE,
CTA_HEADLINE,
CTA_URL_PLACEHOLDER,
} from "@/features/home/constants/cta_contents";
} from "@/features/home/content/cta";
import { useAnalyze } from "@/features/home/hooks/useAnalyze";
import { useInView } from "@/hooks/useInView";

View File

@ -7,7 +7,7 @@ import {
HERO_LEAD_EN,
HERO_LEAD_KO,
HERO_URL_PLACEHOLDER,
} from "@/features/home/constants/hero_contents";
} from "@/features/home/content/hero";
import { useAnalyze } from "@/features/home/hooks/useAnalyze";
export function HeroSection() {

View File

@ -1,4 +1,4 @@
import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/constants/problem_contents";
import { PROBLEM_CARDS, PROBLEM_CARD_STAGGER } from "@/features/home/content/problem";
import { useInView } from "@/hooks/useInView";
export function ProblemSection() {

View File

@ -1,4 +1,4 @@
import { SOLUTION_CARDS } from "@/features/home/constants/solution_contents";
import { SOLUTION_CARDS } from "@/features/home/content/solution";
import { useInView } from "@/hooks/useInView";
export function SolutionSection() {

View File

@ -1,4 +1,4 @@
import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/constants/modules_contents";
import { CORE_MODULES, MODULE_CARD_STAGGER } from "@/features/home/content/modules";
import { useInView } from "@/hooks/useInView";
import { CoreModuleCard } from "./system/CoreModuleCard";
import { CoreModulesCenterHeading } from "./system/CoreModulesCenterHeading";

View File

@ -1,5 +1,5 @@
import CheckCircleIcon from "@/assets/home/check-circle.svg?react";
import { USE_CASE_CARDS } from "@/features/home/constants/use_cases_contents";
import { USE_CASE_CARDS } from "@/features/home/content/useCases";
import { useInView } from "@/hooks/useInView";
export function UseCaseSection() {

View File

@ -1,4 +1,4 @@
import { AGDP_NODES } from "@/features/home/constants/process_contents";
import { AGDP_NODES } from "@/features/home/content/process";
import { AgdpOrbitNode } from "./AgdpOrbitNode";
import { AgdpRewardPathLabel } from "./AgdpRewardPathLabel";

View File

@ -1,5 +1,5 @@
import type { AgdpNodeDef } from "@/features/home/constants/process_contents";
import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/constants/process_contents";
import type { AgdpNodeDef } from "@/features/home/content/process";
import { AGDP_SLOT_WRAPPER_CLASS } from "@/features/home/content/process";
type Props = { node: AgdpNodeDef };

View File

@ -1,4 +1,4 @@
import type { CoreModule } from "@/features/home/constants/modules_contents";
import type { CoreModule } from "@/features/home/content/modules";
type Props = {
mod: CoreModule;

View File

@ -0,0 +1,14 @@
/**
* 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"];

View File

@ -0,0 +1,11 @@
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);
}

View File

@ -0,0 +1,20 @@
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 }),
[],
);
}

View File

@ -0,0 +1,52 @@
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]);
}

View File

@ -0,0 +1,272 @@
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'] },
],
},
};

View File

@ -0,0 +1,176 @@
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;
}

View File

@ -0,0 +1,22 @@
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>
);
}

View File

@ -0,0 +1,22 @@
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>
);
}

View File

@ -0,0 +1,23 @@
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>
);
}

View File

@ -0,0 +1,23 @@
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>
);
}

View File

@ -0,0 +1,22 @@
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>
);
}

View File

@ -0,0 +1,118 @@
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>
);
}

View File

@ -0,0 +1,34 @@
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>
);
}

View File

@ -0,0 +1,19 @@
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>
);
}

View File

@ -0,0 +1,40 @@
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>
);
}

View File

@ -0,0 +1,122 @@
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>
);
}

View File

@ -0,0 +1,75 @@
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);
}

View File

@ -0,0 +1,10 @@
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"];

View File

@ -0,0 +1,35 @@
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 />;
}

View File

@ -0,0 +1,52 @@
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>
);
}

View File

@ -0,0 +1,45 @@
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>
);
}

View File

@ -0,0 +1,69 @@
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&apos;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>
);
}

View File

@ -0,0 +1,92 @@
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>
);
}

View File

@ -0,0 +1,27 @@
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;
}
}

View File

@ -0,0 +1,8 @@
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"];

View File

@ -0,0 +1,86 @@
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>
);
}

View File

@ -0,0 +1,14 @@
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";
}
}

View File

@ -0,0 +1,110 @@
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>
);
}

View File

@ -0,0 +1,25 @@
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 />;
}

View File

@ -0,0 +1,69 @@
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;

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