Compare commits

...

5 Commits

41 changed files with 1908 additions and 36 deletions

4
.gitignore vendored
View File

@ -12,6 +12,8 @@ dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@ -22,3 +24,5 @@ dist-ssr
*.njsproj
*.sln
*.sw?
zzz/

View File

@ -21,6 +21,25 @@ src/
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
│ ├── channelconnect/ # 채널 연결 도메인
│ │ ├── constants/ # 채널 목록 등 상수
│ │ ├── hooks/ # 연결 플로우용 훅
│ │ ├── store/ # 연결 채널·카운트 등 상태
│ │ ├── types/ # 도메인 타입
│ │ ├── utils/ # 채널 아이콘 매핑 등
│ │ └── ui/ # 타이틀·섹션 UI
│ ├── distribution/ # 콘텐츠 배포 도메인
│ │ ├── constants/ # 문구·옵션 상수
│ │ ├── hooks/ # 배포·예약 게시 훅
│ │ ├── store/ # 예약·선택 채널 등 상태
│ │ ├── types/ # 도메인 타입
│ │ └── ui/ # 채널 선택, 예약 게시, 미리보기 등
│ ├── performance/ # 성과 도메인
│ │ ├── constants/ # 지표·섹션 설정 상수
│ │ ├── hooks/ # 데이터 로딩·필터 훅
│ │ ├── store/ # 화면 전용 상태
│ │ ├── types/ # 지표·성과 타입
│ │ └── ui/ # 요약, 퍼널, 히트맵, 추천 등
│ ├── report/ # 리포트 도메인
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅

85
package-lock.json generated
View File

@ -10,6 +10,8 @@
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"framer-motion": "^12.38.0",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",
@ -65,6 +67,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -1566,6 +1569,7 @@
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/core": "^7.21.3",
"@svgr/babel-preset": "8.1.0",
@ -2246,6 +2250,7 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -2256,6 +2261,7 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -2315,6 +2321,7 @@
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.0",
"@typescript-eslint/types": "8.57.0",
@ -2592,6 +2599,7 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2717,6 +2725,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3142,6 +3151,7 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -3453,6 +3463,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.38.0",
"motion-utils": "^12.36.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -3927,6 +3964,47 @@
"node": "*"
}
},
"node_modules/motion": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz",
"integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.38.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": {
"version": "12.38.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.36.0"
}
},
"node_modules/motion-utils": {
"version": "12.36.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -4170,6 +4248,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -4179,6 +4258,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -4250,6 +4330,7 @@
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -4464,7 +4545,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
@ -4486,6 +4566,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -4572,6 +4653,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -4723,6 +4805,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@ -12,6 +12,8 @@
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.6",
"framer-motion": "^12.38.0",
"motion": "^12.38.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.1",

View File

@ -6,6 +6,9 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
// pages
import { Home } from "@/pages/Home";
import { PlanPage } from "@/pages/Plan";
import { ChannelConnect } from "@/pages/ChannelConnect";
import { Distribution } from "@/pages/Distribution";
import { Performance } from "@/pages/Performance";
import { ReportPage } from "@/pages/Report";
function App() {
@ -14,6 +17,9 @@ function App() {
<Routes>
<Route element={<MainLayout />}>
<Route index element={<Home />} />
<Route path="channels" element={<ChannelConnect />} />
<Route path="distribute" element={<Distribution />} />
<Route path="performance" element={<Performance />} />
</Route>
<Route element={<MainSubNavLayout />}>
<Route path="report/:id" element={<ReportPage />} />

View File

@ -0,0 +1,161 @@
/**
* Filled/Shape-style icons for Channel Strategy & Content Calendar.
* Soft pastel colors, no outlines all shapes use fill only.
*/
interface IconProps {
size?: number;
className?: string;
color?: string;
style?: { color?: string; [key: string]: unknown };
}
export function YoutubeFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="2" y="4" width="20" height="16" rx="4" fill="currentColor" opacity="0.25" />
<path d="M10 8.5v7l6-3.5-6-3.5z" fill="currentColor" />
</svg>
);
}
export function InstagramFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<circle cx="12" cy="12" r="4.5" fill="currentColor" />
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
</svg>
);
}
export function FacebookFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M15.5 3.5H13.5C11.29 3.5 9.5 5.29 9.5 7.5V9.5H7.5V12.5H9.5V20.5H12.5V12.5H14.5L15.5 9.5H12.5V7.5C12.5 7.22 12.72 7 13 7H15.5V3.5Z" fill="currentColor" />
</svg>
);
}
export function GlobeFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2" />
<ellipse cx="12" cy="12" rx="4" ry="10" fill="currentColor" opacity="0.35" />
<line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
<line x1="12" y1="2" x2="12" y2="22" stroke="currentColor" strokeWidth="1.5" opacity="0.3" />
</svg>
);
}
export function VideoFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.25" />
<path d="M16 9.5L22 6.5V17.5L16 14.5V9.5Z" fill="currentColor" />
<rect x="1" y="5" width="15" height="14" rx="3" fill="currentColor" opacity="0.5" />
</svg>
);
}
export function MessageFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.3" />
<path d="M4 4H20C21.1 4 22 4.9 22 6V16C22 17.1 21.1 18 20 18H6L2 22V6C2 4.9 2.9 4 4 4Z" fill="currentColor" opacity="0.4" />
</svg>
);
}
export function CalendarFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="3" y="4" width="18" height="18" rx="3" fill="currentColor" opacity="0.25" />
<rect x="3" y="4" width="18" height="6" rx="3" fill="currentColor" opacity="0.5" />
<circle cx="8" cy="15" r="1.2" fill="currentColor" />
<circle cx="12" cy="15" r="1.2" fill="currentColor" />
<circle cx="16" cy="15" r="1.2" fill="currentColor" />
<line x1="8" y1="2" x2="8" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
<line x1="16" y1="2" x2="16" y2="5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
export function FileTextFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<path d="M6 2H14L20 8V20C20 21.1 19.1 22 18 22H6C4.9 22 4 21.1 4 20V4C4 2.9 4.9 2 6 2Z" fill="currentColor" opacity="0.25" />
<path d="M14 2L20 8H16C14.9 8 14 7.1 14 6V2Z" fill="currentColor" opacity="0.5" />
<rect x="8" y="12" width="8" height="1.5" rx="0.75" fill="currentColor" />
<rect x="8" y="16" width="5" height="1.5" rx="0.75" fill="currentColor" />
</svg>
);
}
export function ShareFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<circle cx="18" cy="5" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="6" cy="12" r="3.5" fill="currentColor" opacity="0.4" />
<circle cx="18" cy="19" r="3.5" fill="currentColor" opacity="0.4" />
<line x1="9" y1="10.5" x2="15" y2="6.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
<line x1="9" y1="13.5" x2="15" y2="17.5" stroke="currentColor" strokeWidth="2" opacity="0.3" />
</svg>
);
}
export function MegaphoneFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<path d="M19 3L8 8H4C2.9 8 2 8.9 2 10V14C2 15.1 2.9 16 4 16H5L7 21H10L8 16L19 21V3Z" fill="currentColor" opacity="0.35" />
<path d="M21 10C22.1 10 23 10.9 23 12C23 13.1 22.1 14 21 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.5" />
</svg>
);
}
export function TiktokFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<rect x="2" y="2" width="20" height="20" rx="6" fill="currentColor" opacity="0.25" />
<path d="M16.5 4.5V12.5C16.5 15.26 14.26 17.5 11.5 17.5C8.74 17.5 6.5 15.26 6.5 12.5C6.5 9.74 8.74 7.5 11.5 7.5V10C10.12 10 9 11.12 9 12.5C9 13.88 10.12 15 11.5 15C12.88 15 14 13.88 14 12.5V4.5H16.5Z" fill="currentColor" />
</svg>
);
}
export function MusicFilled({ size = 20, className = '', color, style }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className} style={style ?? { color }}>
<circle cx="7" cy="17" r="3" fill="currentColor" opacity="0.25" />
<circle cx="17" cy="15" r="3" fill="currentColor" opacity="0.25" />
<circle cx="7" cy="17" r="2" fill="currentColor" />
<circle cx="17" cy="15" r="2" fill="currentColor" />
<path d="M9 17V7L19 5V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
<rect x="9" y="5" width="10" height="2" rx="1" fill="currentColor" opacity="0.4" />
</svg>
);
}
export function PrismFilled({ size = 20, className = '', color, style }: IconProps) {
const w = Math.round(size * 1.6);
const h = size;
const id = `inf-grad-${size}`;
return (
<svg width={w} height={h} viewBox="0 0 28 20" fill="none" className={className} style={style ?? { color }}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="28" y2="20" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="45%" stopColor="currentColor" stopOpacity="0.55" />
<stop offset="100%" stopColor="currentColor" stopOpacity="0.85" />
</linearGradient>
</defs>
<path
d="M2,10 C2,4 8,4 14,10 C20,16 26,16 26,10 C26,4 20,4 14,10 C8,16 2,16 2,10Z"
stroke={`url(#${id})`}
strokeWidth="2.8"
strokeLinejoin="round"
fill="none"
/>
</svg>
);
}

View File

@ -0,0 +1,128 @@
/** 채널 연결 목록 */
import type { ChannelDef } from '../types';
export const CHANNELS: ChannelDef[] = [
{
id: 'youtube',
name: 'YouTube',
description: '채널 연동으로 영상 자동 업로드, 성과 분석',
iconKey: 'youtube',
brandColor: '#FF0000',
bgColor: '#FFF0F0',
borderColor: '#F5D5DC',
fields: [
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
],
},
{
id: 'tiktok',
name: 'TikTok',
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
iconKey: 'tiktok',
brandColor: '#000000',
bgColor: '#F5F5F5',
borderColor: '#E0E0E0',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
],
},
{
id: 'instagram_kr',
name: 'Instagram KR',
description: '한국 계정 — Reels, Feed, Stories 자동 게시',
iconKey: 'instagram',
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewclinic_kr' },
],
},
{
id: 'instagram_en',
name: 'Instagram EN',
description: '글로벌 계정 — 해외 환자 대상 콘텐츠',
iconKey: 'instagram',
brandColor: '#E1306C',
bgColor: '#FFF0F5',
borderColor: '#F5D0DC',
fields: [
{ key: 'handle', label: '핸들', placeholder: '@viewplasticsurgery' },
],
},
{
id: 'facebook_kr',
name: 'Facebook KR',
description: '한국 페이지 — 광고 리타겟, 콘텐츠 배포',
iconKey: 'facebook',
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPage' },
],
},
{
id: 'facebook_en',
name: 'Facebook EN',
description: '글로벌 페이지 — 해외 환자 유입',
iconKey: 'facebook',
brandColor: '#1877F2',
bgColor: '#F0F4FF',
borderColor: '#C5D5F5',
fields: [
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPageEN' },
],
},
{
id: 'naver_blog',
name: 'Naver Blog',
description: 'SEO 블로그 포스트 자동 게시, 키워드 최적화',
iconKey: 'globe',
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'blogUrl', label: '블로그 URL', placeholder: 'https://blog.naver.com/yourblog' },
{ key: 'apiKey', label: 'API Key', placeholder: 'Naver Open API Key', type: 'password' },
],
},
{
id: 'naver_place',
name: 'Naver Place',
description: '플레이스 정보 동기화, 리뷰 모니터링',
iconKey: 'globe',
brandColor: '#03C75A',
bgColor: '#F0FFF5',
borderColor: '#C5F5D5',
fields: [
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
],
},
{
id: 'gangnamunni',
name: '강남언니',
description: '리뷰 모니터링, 평점 추적, 시술 정보 동기화',
iconKey: 'globe',
brandColor: '#6B2D8B',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'hospitalUrl', label: '병원 페이지 URL', placeholder: 'https://gangnamunni.com/hospitals/...' },
],
},
{
id: 'website',
name: 'Website',
description: '홈페이지 SEO 모니터링, 트래킹 픽셀 관리',
iconKey: 'globe',
brandColor: '#6C5CE7',
bgColor: '#F3F0FF',
borderColor: '#D5CDF5',
fields: [
{ key: 'url', label: '웹사이트 URL', placeholder: 'https://www.yourclinic.com' },
{ key: 'gaId', label: 'Google Analytics ID', placeholder: 'G-XXXXXXXXXX' },
],
},
];

View File

@ -0,0 +1,57 @@
import { useState, useCallback } from 'react';
import { CHANNELS } from '../constants/channels';
import type { ChannelState } from '../types';
export function useChannelConnect() {
const [channels, setChannels] = useState<Record<string, ChannelState>>(() => {
const init: Record<string, ChannelState> = {};
for (const ch of CHANNELS) {
init[ch.id] = { status: 'disconnected', values: {} };
}
return init;
});
const [expandedId, setExpandedId] = useState<string | null>(null);
const handleFieldChange = useCallback((channelId: string, fieldKey: string, value: string) => {
setChannels(prev => ({
...prev,
[channelId]: {
...prev[channelId],
values: { ...prev[channelId].values, [fieldKey]: value },
},
}));
}, []);
const handleConnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connecting' },
}));
setTimeout(() => {
setChannels(prev => ({
...prev,
[channelId]: { ...prev[channelId], status: 'connected' },
}));
}, 2000);
}, []);
const handleDisconnect = useCallback((channelId: string) => {
setChannels(prev => ({
...prev,
[channelId]: { status: 'disconnected', values: {} },
}));
}, []);
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
return {
channels,
expandedId,
connectedCount,
setExpandedId,
handleFieldChange,
handleConnect,
handleDisconnect,
};
}

View File

@ -0,0 +1,61 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { CHANNELS } from '../constants/channels';
import type { ChannelState } from '../types';
interface ChannelConnectStore {
channels: Record<string, ChannelState>;
expandedId: string | null;
connectedCount: number;
setExpandedId: (id: string | null) => void;
handleFieldChange: (channelId: string, fieldKey: string, value: string) => void;
handleConnect: (channelId: string) => void;
handleDisconnect: (channelId: string) => void;
}
const initialChannels: Record<string, ChannelState> = {};
for (const ch of CHANNELS) {
initialChannels[ch.id] = { status: 'disconnected', values: {} };
}
export const useChannelConnectStore = create<ChannelConnectStore>()(
persist(
(set) => ({
channels: initialChannels,
expandedId: null,
connectedCount: 0,
setExpandedId: (expandedId) => set({ expandedId }),
handleFieldChange: (channelId, fieldKey, value) => set((s) => ({
channels: {
...s.channels,
[channelId]: {
...s.channels[channelId],
values: { ...s.channels[channelId].values, [fieldKey]: value },
},
},
})),
handleConnect: (channelId) => {
set((s) => ({
channels: { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connecting' } },
}));
setTimeout(() => {
set((s) => {
const channels = { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connected' as const } };
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
return { channels, connectedCount };
});
}, 2000);
},
handleDisconnect: (channelId) => set((s) => {
const channels = { ...s.channels, [channelId]: { status: 'disconnected' as const, values: {} } };
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
return { channels, connectedCount };
}),
}),
{ name: 'channel-connect' }
)
);

View File

@ -0,0 +1,17 @@
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
export interface ChannelDef {
id: string;
name: string;
description: string;
iconKey: string;
brandColor: string;
bgColor: string;
borderColor: string;
fields: { key: string; label: string; placeholder: string; type?: string }[];
}
export interface ChannelState {
status: ConnectionStatus;
values: Record<string, string>;
}

View File

@ -0,0 +1,170 @@
import { motion, AnimatePresence } from 'motion/react';
import type { ChannelDef, ChannelState } from '../types';
import { CHANNELS } from '../constants/channels';
import { useChannelConnectStore } from '../store/channelConnectStore';
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
interface ChannelCardProps {
ch: ChannelDef;
state: ChannelState;
isExpanded: boolean;
onToggle: () => void;
onFieldChange: (fieldKey: string, value: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
function ChannelCard({ ch, state, isExpanded, onToggle, onFieldChange, onConnect, onDisconnect }: ChannelCardProps) {
const Icon = CHANNEL_ICON_MAP[ch.iconKey];
const allFieldsFilled = ch.fields.every(f => (state.values[f.key] ?? '').trim().length > 0);
return (
<motion.div
layout
className={`rounded-2xl border-2 overflow-hidden transition-all ${
state.status === 'connected'
? 'border-[#D5CDF5] bg-[#F3F0FF]/20'
: isExpanded
? 'border-[#6C5CE7] bg-white shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
}`}
>
<button
onClick={onToggle}
className="w-full flex items-center gap-4 p-5 text-left"
>
<div
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{ backgroundColor: ch.bgColor }}
>
<Icon size={22} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[#0A1128]">{ch.name}</h3>
{state.status === 'connected' && (
<span className="px-2 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-semibold border border-[#D5CDF5]">
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-1 truncate">{ch.description}</p>
</div>
<svg
width="20" height="20" viewBox="0 0 20 20" fill="none"
className={`shrink-0 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
>
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
{state.status === 'connected' ? (
<div>
<div className="flex items-center gap-2 mb-4 mt-3">
<div className="w-2 h-2 rounded-full bg-[#6C5CE7]" />
<span className="text-sm text-[#4A3A7C] font-medium"> : </span>
</div>
{ch.fields.map(f => (
<div key={f.key} className="mb-2">
<span className="text-xs text-slate-400">{f.label}</span>
<p className="text-sm text-slate-700 font-medium">
{f.type === 'password' ? '••••••••' : state.values[f.key]}
</p>
</div>
))}
<button
onClick={onDisconnect}
className="mt-4 w-full py-3 rounded-full bg-white border border-[#F5D5DC] text-[#7C3A4B] text-sm font-medium hover:bg-[#FFF0F0] transition-all"
>
</button>
</div>
) : (
<div className="mt-3">
{ch.fields.map(f => (
<div key={f.key} className="mb-3">
<label className="text-xs font-medium text-slate-600 mb-1 block">{f.label}</label>
<input
type={f.type ?? 'text'}
value={state.values[f.key] ?? ''}
onChange={e => onFieldChange(f.key, e.target.value)}
placeholder={f.placeholder}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 placeholder:text-slate-300 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
))}
<div className="flex gap-2 mt-4">
<button
onClick={onConnect}
disabled={!allFieldsFilled || state.status === 'connecting'}
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{state.status === 'connecting' ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
...
</>
) : (
'연결하기'
)}
</button>
<button
className="px-4 py-3 rounded-full bg-white border border-slate-200 text-slate-500 text-sm font-medium hover:bg-slate-50 transition-all"
>
OAuth
</button>
</div>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export function ChannelConnectSection() {
const {
channels,
expandedId,
setExpandedId,
handleFieldChange,
handleConnect,
handleDisconnect,
} = useChannelConnectStore();
return (
<section className="py-12 px-6">
<div className="max-w-5xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CHANNELS.map(ch => (
<ChannelCard
key={ch.id}
ch={ch}
state={channels[ch.id]}
isExpanded={expandedId === ch.id}
onToggle={() => setExpandedId(expandedId === ch.id ? null : ch.id)}
onFieldChange={(fieldKey, value) => handleFieldChange(ch.id, fieldKey, value)}
onConnect={() => handleConnect(ch.id)}
onDisconnect={() => handleDisconnect(ch.id)}
/>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,42 @@
import { useNavigate } from 'react-router';
import { CHANNELS } from '../constants/channels';
import { useChannelConnectStore } from '../store/channelConnectStore';
export function ChannelConnectTitle() {
const { connectedCount } = useChannelConnectStore();
const navigate = useNavigate();
return (
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] py-16 px-6">
<div className="max-w-5xl mx-auto">
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-3">Channel Integration</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-[#021341] mb-3">
</h1>
<p className="text-[#021341]/60 max-w-xl">
.
</p>
<div className="flex items-center gap-4 mt-8">
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/70 backdrop-blur-sm border border-white/40">
<div className={`w-3 h-3 rounded-full ${connectedCount > 0 ? 'bg-[#6C5CE7]' : 'bg-slate-300'}`} />
<span className="text-sm font-medium text-[#021341]">
{connectedCount} / {CHANNELS.length}
</span>
</div>
{connectedCount > 0 && (
<button
onClick={() => navigate('/distribute')}
className="flex items-center gap-2 px-5 py-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
import { ChannelConnectTitle } from './ChannelConnectTitle';
import { ChannelConnectSection } from './ChannelConnectSection';
export {
ChannelConnectTitle,
ChannelConnectSection,
};

View File

@ -0,0 +1,18 @@
import type { ComponentType } from 'react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
} from '@/components/icons/FilledIcons';
type IconComponent = ComponentType<{ size?: number; className?: string; color?: string; style?: { color?: string; [key: string]: unknown } }>;
export const CHANNEL_ICON_MAP: Record<string, IconComponent> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
tiktok: TiktokFilled,
};

View File

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

View File

@ -0,0 +1,61 @@
import { useState, useCallback } from 'react';
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution';
export function useDistribution() {
const [channels, setChannels] = useState(INITIAL_CHANNELS);
const [title, setTitle] = useState(MOCK_CONTENT.title);
const [description, setDescription] = useState(MOCK_CONTENT.description);
const [tags] = useState(MOCK_CONTENT.tags);
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
const [scheduleDate, setScheduleDate] = useState('');
const [scheduleHour, setScheduleHour] = useState(9);
const [scheduleMinute, setScheduleMinute] = useState(0);
const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM');
const [isPublishing, setIsPublishing] = useState(false);
const selectedChannels = channels.filter(c => c.connected && c.selected);
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
const toggleChannel = useCallback((id: string) => {
setChannels(prev => prev.map(c =>
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
));
}, []);
const handlePublish = useCallback(() => {
setIsPublishing(true);
const selected = channels.filter(c => c.connected && c.selected);
selected.forEach((ch, i) => {
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'publishing' } : c
));
}, i * 1500);
setTimeout(() => {
setChannels(prev => prev.map(c =>
c.id === ch.id ? { ...c, status: 'published' } : c
));
if (i === selected.length - 1) setIsPublishing(false);
}, (i + 1) * 1500 + 500);
});
}, [channels]);
return {
channels,
title, setTitle,
description, setDescription,
tags,
scheduleMode, setScheduleMode,
scheduleDate, setScheduleDate,
scheduleHour, setScheduleHour,
scheduleMinute, setScheduleMinute,
schedulePeriod, setSchedulePeriod,
isPublishing,
selectedChannels,
allPublished,
toggleChannel,
handlePublish,
};
}

View File

@ -0,0 +1,73 @@
import { create } from 'zustand';
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution';
import type { ChannelTarget } from '../types';
interface DistributionStore {
channels: ChannelTarget[];
title: string;
description: string;
tags: string[];
scheduleMode: 'now' | 'scheduled';
scheduleDate: string;
scheduleHour: number;
scheduleMinute: number;
schedulePeriod: 'AM' | 'PM';
isPublishing: boolean;
setTitle: (v: string) => void;
setDescription: (v: string) => void;
setScheduleMode: (v: 'now' | 'scheduled') => void;
setScheduleDate: (v: string) => void;
setScheduleHour: (fn: (h: number) => number) => void;
setScheduleMinute: (fn: (m: number) => number) => void;
setSchedulePeriod: (v: 'AM' | 'PM') => void;
toggleChannel: (id: string) => void;
handlePublish: (selectedIds: string[]) => void;
}
export const useDistributionStore = create<DistributionStore>((set, get) => ({
channels: INITIAL_CHANNELS,
title: MOCK_CONTENT.title,
description: MOCK_CONTENT.description,
tags: MOCK_CONTENT.tags,
scheduleMode: 'now',
scheduleDate: '',
scheduleHour: 9,
scheduleMinute: 0,
schedulePeriod: 'AM',
isPublishing: false,
setTitle: (title) => set({ title }),
setDescription: (description) => set({ description }),
setScheduleMode: (scheduleMode) => set({ scheduleMode }),
setScheduleDate: (scheduleDate) => set({ scheduleDate }),
setScheduleHour: (fn) => set((s) => ({ scheduleHour: fn(s.scheduleHour) })),
setScheduleMinute: (fn) => set((s) => ({ scheduleMinute: fn(s.scheduleMinute) })),
setSchedulePeriod: (schedulePeriod) => set({ schedulePeriod }),
toggleChannel: (id) => set((s) => ({
channels: s.channels.map(c =>
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
),
})),
handlePublish: (selectedIds) => {
const { channels } = get();
const selected = channels.filter(c => selectedIds.includes(c.id));
set({ isPublishing: true });
selected.forEach((ch, i) => {
setTimeout(() => {
set((s) => ({
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'publishing' } : c),
}));
}, i * 1500);
setTimeout(() => {
set((s) => ({
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'published' } : c),
...(i === selected.length - 1 ? { isPublishing: false } : {}),
}));
}, (i + 1) * 1500 + 500);
});
},
}));

View File

@ -0,0 +1,25 @@
import type { ComponentType } from 'react';
export type DistributeStatus = 'ready' | 'publishing' | 'published' | 'failed';
export interface ChannelTarget {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string; style?: { color?: string } }>;
brandColor: string;
bgColor: string;
connected: boolean;
selected: boolean;
status: DistributeStatus;
format: string;
}
export interface MockContent {
title: string;
description: string;
type: 'video';
duration: string;
aspectRatio: string;
tags: string[];
thumbnail: string | null;
}

View File

@ -0,0 +1,76 @@
import { motion } from 'motion/react';
import { useDistributionStore } from '../store/distributionStore';
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
export function ChannelSelectSection() {
const { channels, toggleChannel } = useDistributionStore();
const { channels: connectedChannels } = useChannelConnectStore();
const mergedChannels = channels.map(ch => ({
...ch,
connected: connectedChannels[ch.id]?.status === 'connected',
}));
return (
<div className="mb-8">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="space-y-3">
{mergedChannels.map(ch => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
layout
className={`flex items-center gap-4 p-4 rounded-2xl border-2 transition-all ${
!ch.connected
? 'border-slate-100 bg-slate-50/50 opacity-50'
: ch.selected
? 'border-[#6C5CE7] bg-[#F3F0FF]/20 shadow-[3px_4px_12px_rgba(108,92,231,0.08)]'
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.04)] hover:border-[#D5CDF5]'
}`}
>
<button
onClick={() => toggleChannel(ch.id)}
disabled={!ch.connected}
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
ch.selected && ch.connected ? 'border-[#6C5CE7] bg-[#6C5CE7]' : 'border-slate-300 bg-white'
} disabled:cursor-not-allowed`}
>
{ch.selected && ch.connected && (
<svg width="10" height="10" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
{!ch.connected && (
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]"></span>
)}
</div>
<p className="text-xs text-slate-400">{ch.format}</p>
</div>
<div className="shrink-0">
{ch.status === 'publishing' && <div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />}
{ch.status === 'published' && (
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)}
{ch.status === 'failed' && (
<div className="w-6 h-6 rounded-full bg-[#FFF0F0] flex items-center justify-center border border-[#F5D5DC]">
<span className="text-[#7C3A4B] text-xs font-bold">!</span>
</div>
)}
</div>
</motion.div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
import { VideoFilled } from '@/components/icons/FilledIcons';
import { MOCK_CONTENT } from '../constants/distribution';
import { useDistributionStore } from '../store/distributionStore';
export function ContentPreviewSection() {
const { title, setTitle, description, setDescription, tags } = useDistributionStore();
return (
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"></h3>
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] border border-slate-200 flex flex-col items-center justify-center mb-6">
<VideoFilled size={32} className="text-[#9B8AD4] mb-3" />
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
</div>
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<input
value={title}
onChange={e => setTitle(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
<div className="mb-4">
<label className="text-xs font-medium text-slate-600 mb-1 block"></label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={4}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex flex-wrap gap-2">
{tags.map(tag => (
<span key={tag} className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
#{tag}
</span>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,19 @@
import { ContentPreviewSection } from './ContentPreview';
import { ChannelSelectSection } from './ChannelSelect';
import { SchedulePublishSection } from './SchedulePublish';
export function DistributionSection() {
return (
<section className="py-10 px-6">
<div className="max-w-5xl mx-auto w-full">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<ContentPreviewSection />
<div className="lg:col-span-2 flex flex-col gap-8">
<ChannelSelectSection />
<SchedulePublishSection />
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,16 @@
export function DistributionTitle() {
return (
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
<div className="max-w-5xl mx-auto relative">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Content Distribution</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">
</h1>
<p className="text-purple-200/70 max-w-xl">
.
</p>
</div>
</div>
);
}

View File

@ -0,0 +1,138 @@
import { motion } from 'motion/react';
import { ShareFilled } from '@/components/icons/FilledIcons';
import { useDistributionStore } from '../store/distributionStore';
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
export function SchedulePublishSection() {
const {
channels,
scheduleMode, setScheduleMode,
scheduleDate, setScheduleDate,
scheduleHour, setScheduleHour,
scheduleMinute, setScheduleMinute,
schedulePeriod, setSchedulePeriod,
isPublishing,
handlePublish,
} = useDistributionStore();
const { channels: connectedChannels } = useChannelConnectStore();
const mergedChannels = channels.map(ch => ({
...ch,
connected: connectedChannels[ch.id]?.status === 'connected',
}));
const selectedChannels = mergedChannels.filter(c => c.connected && c.selected);
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
return (
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="flex gap-2 mb-4">
{([
{ key: 'now' as const, label: '즉시 배포' },
{ key: 'scheduled' as const, label: '예약 배포' },
]).map(opt => (
<button
key={opt.key}
onClick={() => setScheduleMode(opt.key)}
className={`px-5 py-3 rounded-full text-sm font-medium transition-all ${
scheduleMode === opt.key
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white shadow-md'
: 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{opt.label}
</button>
))}
</div>
{scheduleMode === 'scheduled' && (
<div className="mb-6 space-y-4">
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<input
type="date"
value={scheduleDate}
onChange={e => setScheduleDate(e.target.value)}
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] transition-all appearance-none"
/>
</div>
<div>
<label className="text-xs font-medium text-slate-600 mb-2 block"></label>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleHour).padStart(2, '0')}
</div>
<button onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<span className="text-xl font-bold text-slate-300">:</span>
<div className="flex items-center gap-1">
<button onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
{String(scheduleMinute).padStart(2, '0')}
</div>
<button onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
</button>
</div>
<div className="flex rounded-xl overflow-hidden border border-slate-200">
{(['AM', 'PM'] as const).map(p => (
<button
key={p}
onClick={() => setSchedulePeriod(p)}
className={`px-4 py-2 text-sm font-medium transition-all ${
schedulePeriod === p
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: 'bg-white text-slate-500 hover:bg-slate-50'
}`}
>
{p}
</button>
))}
</div>
</div>
</div>
</div>
)}
{!allPublished ? (
<button
onClick={() => handlePublish(selectedChannels.map(c => c.id))}
disabled={selectedChannels.length === 0 || isPublishing}
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
{isPublishing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
... ({selectedChannels.filter(c => c.status === 'published').length}/{selectedChannels.length})
</>
) : (
<>
<ShareFilled size={18} className="text-white" />
{selectedChannels.length} {scheduleMode === 'now' ? '즉시 배포' : '예약 배포'}
</>
)}
</button>
) : (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="w-full py-6 rounded-2xl bg-[#F3F0FF] border border-[#D5CDF5] text-center"
>
<div className="w-12 h-12 rounded-full bg-[#6C5CE7] flex items-center justify-center mx-auto mb-3">
<svg width="20" height="20" viewBox="0 0 14 14" fill="none">
<path d="M2 7L5.5 10.5L12 3.5" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<p className="text-lg font-semibold text-[#0A1128] mb-1"> </p>
<p className="text-sm text-[#4A3A7C]">{selectedChannels.length} </p>
</motion.div>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
import { DistributionTitle } from './DistributionTitle';
import { DistributionSection } from './DistributionSection';
export {
DistributionTitle,
DistributionSection,
};

View File

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

View File

@ -0,0 +1,11 @@
import { useState } from 'react';
import { FUNNEL_STEPS, CHANNEL_TREND } from '../constants/performance';
export function usePerformance() {
const [period, setPeriod] = useState<'7d' | '30d' | '90d'>('30d');
const funnelMax = FUNNEL_STEPS[0].value;
const trendMax = Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook]));
return { period, setPeriod, funnelMax, trendMax };
}

View File

@ -0,0 +1,16 @@
import { create } from 'zustand';
import { FUNNEL_STEPS, CHANNEL_TREND } from '../constants/performance';
interface PerformanceStore {
period: '7d' | '30d' | '90d';
setPeriod: (p: '7d' | '30d' | '90d') => void;
funnelMax: number;
trendMax: number;
}
export const usePerformanceStore = create<PerformanceStore>((set) => ({
period: '30d',
setPeriod: (period) => set({ period }),
funnelMax: FUNNEL_STEPS[0].value,
trendMax: Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook])),
}));

View File

@ -0,0 +1,29 @@
import type { ComponentType } from 'react';
export interface ChannelMetric {
id: string;
name: string;
icon: ComponentType<{ size?: number; className?: string; style?: { color?: string } }>;
brandColor: string;
bgColor: string;
followers: string;
followersDelta: string;
views: string;
viewsDelta: string;
engagement: string;
engagementDelta: string;
posts: number;
score: number;
}
export interface ContentPerformance {
id: string;
title: string;
channel: string;
type: 'video' | 'blog' | 'social';
views: string;
likes: string;
comments: string;
ctr: string;
publishedAt: string;
}

View File

@ -0,0 +1,21 @@
import { AI_RECOMMENDATIONS } from '../constants/performance';
export function AIRecommendationSection() {
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI </h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{AI_RECOMMENDATIONS.map((rec, i) => (
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
</div>
))}
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,63 @@
import { motion } from 'motion/react';
import { CHANNELS } from '../constants/performance';
function MetricCell({ label, value, delta }: { label: string; value: string; delta: string }) {
const isPositive = delta.startsWith('+');
const isNew = delta === 'NEW' || delta === '-';
return (
<div className="text-center">
<p className="text-xs text-slate-400 mb-1">{label}</p>
<p className="text-sm font-semibold text-[#0A1128]">{value}</p>
<p className={`text-xs font-medium ${
isNew ? 'text-slate-400' : isPositive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'
}`}>{delta}</p>
</div>
);
}
export function ChannelPerformanceSection() {
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{CHANNELS.map((ch, i) => {
const Icon = ch.icon;
return (
<motion.div
key={ch.id}
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
<Icon size={20} style={{ color: ch.brandColor }} />
</div>
<div className="flex-1">
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
<p className="text-xs text-slate-400">{ch.posts} </p>
</div>
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
ch.score >= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
'bg-slate-50 text-slate-400'
}`}>
{ch.score || '-'}
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
</div>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,75 @@
import { motion } from 'motion/react';
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
import { usePerformanceStore } from '../store/performanceStore';
export function FunnelSection() {
const { funnelMax } = usePerformanceStore();
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> </p>
</div>
</div>
<div className="space-y-3">
{FUNNEL_STEPS.map((step, i) => {
const widthPct = Math.max((step.value / funnelMax) * 100, 8);
const convRate = i > 0
? ((step.value / FUNNEL_STEPS[i - 1].value) * 100).toFixed(1)
: null;
return (
<motion.div
key={step.label}
className="flex items-center gap-4"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="w-24 shrink-0 text-right">
<p className="text-sm font-medium text-[#0A1128]">{step.label}</p>
<p className="text-xs text-slate-400">{step.labelEn}</p>
</div>
<div className="flex-1 relative">
<motion.div
className="h-10 rounded-xl flex items-center px-4"
style={{ backgroundColor: step.color }}
initial={{ width: 0 }}
animate={{ width: `${widthPct}%` }}
transition={{ duration: 0.7, delay: i * 0.12 }}
>
<span className="text-sm font-bold text-white whitespace-nowrap">{step.display}</span>
</motion.div>
</div>
<div className="w-16 shrink-0 text-right">
{convRate ? (
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
parseFloat(convRate) >= 10
? 'bg-[#F3F0FF] text-[#4A3A7C]'
: 'bg-[#FFF6ED] text-[#7C5C3A]'
}`}>
{convRate}%
</span>
) : (
<span className="text-xs text-slate-300"></span>
)}
</div>
</motion.div>
);
})}
</div>
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,69 @@
import { motion } from 'motion/react';
import { DAYS, TIME_SLOTS, HEATMAP_DATA, HEATMAP_INSIGHT } from '../constants/performance';
function heatmapColor(value: number): string {
if (value >= 9) return 'bg-[#2d2640] text-white';
if (value >= 7) return 'bg-[#4a4460] text-white';
if (value >= 5) return 'bg-[#8e89a8] text-white';
if (value >= 3) return 'bg-[#c8c4d8] text-[#4a4460]';
return 'bg-[#f0eef5] text-[#8e89a8]';
}
export function HeatmapSection() {
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1">× </p>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-slate-400"></span>
{[1, 3, 5, 7, 9].map(v => (
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
))}
<span className="text-xs text-slate-400"></span>
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[500px]">
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
<div />
{TIME_SLOTS.map(slot => (
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
))}
</div>
{DAYS.map((day, di) => (
<motion.div
key={day}
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: di * 0.05 }}
>
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
{HEATMAP_DATA[di].map((val, ti) => (
<div
key={ti}
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-95 cursor-default ${heatmapColor(val)}`}
>
{val > 0 ? `${val * 10}%` : '-'}
</div>
))}
</motion.div>
))}
</div>
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,22 @@
import { MetricCard } from '@/components/card/MetricCard';
import { OVERVIEW_STATS } from '../constants/performance';
export function OverviewStatsSection() {
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{OVERVIEW_STATS.map((stat) => (
<MetricCard
key={stat.label}
label={stat.label}
value={stat.value}
subtext={stat.delta}
trend={stat.positive ? 'up' : 'down'}
/>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,31 @@
import { usePerformanceStore } from '../store/performanceStore';
export function PerformanceTitle() {
const { period, setPeriod } = usePerformanceStore();
return (
<div className="bg-[#0A1128] py-14 px-6">
<div className="max-w-6xl mx-auto">
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3"> </h1>
<p className="text-purple-200/70 max-w-xl mb-8"> .</p>
<div className="flex gap-2">
{([
{ key: '7d' as const, label: '7일' },
{ key: '30d' as const, label: '30일' },
{ key: '90d' as const, label: '90일' },
]).map(p => (
<button
key={p.key}
onClick={() => setPeriod(p.key)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
period === p.key ? 'bg-white text-[#0A1128]' : 'bg-white/10 text-purple-200 hover:bg-white/20'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { motion } from 'motion/react';
import type { ComponentType } from 'react';
import { VideoFilled, FileTextFilled, ShareFilled } from '@/components/icons/FilledIcons';
import { TOP_CONTENT } from '../constants/performance';
const typeIcons: Record<string, ComponentType<{ size?: number; className?: string }>> = {
video: VideoFilled,
blog: FileTextFilled,
social: ShareFilled,
};
const typeColors: Record<string, { bg: string; text: string }> = {
video: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]' },
blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]' },
social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]' },
};
export function TopContentSection() {
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4"> TOP 5</h3>
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-right">CTR</span>
<span className="text-right"></span>
</div>
{TOP_CONTENT.map((content, i) => {
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
const colors = typeColors[content.type] ?? typeColors.blog;
return (
<motion.div
key={content.id}
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
} border-b border-slate-50 last:border-0`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
<TypeIcon size={14} className={colors.text} />
</div>
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
</div>
<span className="text-xs text-slate-500">{content.channel}</span>
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
<span className={`text-sm font-medium text-right ${
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
}`}>{content.ctr}</span>
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
</motion.div>
);
})}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,67 @@
import { motion } from 'motion/react';
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
import { usePerformanceStore } from '../store/performanceStore';
export function TrendSection() {
const { trendMax } = usePerformanceStore();
return (
<section className="py-10 px-6">
<div className="max-w-6xl mx-auto">
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="font-serif font-bold text-xl text-[#0A1128]"> </h3>
<p className="text-xs text-slate-500 mt-1"> (: K)</p>
</div>
<div className="flex gap-3">
{TREND_CHANNELS.map(ch => (
<div key={ch.key} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
<span className="text-xs text-slate-500">{ch.label}</span>
</div>
))}
</div>
</div>
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
{CHANNEL_TREND.map((week, wi) => {
const total = week.youtube + week.instagram + week.naver + week.facebook;
return (
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
<p className="text-xs text-slate-500 font-medium">{total}K</p>
<div className="w-full flex flex-col-reverse items-stretch" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
{TREND_CHANNELS.map((ch, ci) => {
const val = week[ch.key];
const segH = (val / total) * 100;
const isFirst = ci === 0;
const isLast = ci === TREND_CHANNELS.length - 1;
return (
<motion.div
key={ch.key}
className={`w-full relative group ${isFirst ? 'rounded-b-xl' : ''} ${isLast ? 'rounded-t-xl' : ''}`}
style={{ height: `${segH}%`, backgroundColor: ch.color }}
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.5, delay: wi * 0.1 }}
>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
{ch.label}: {val}K
</div>
</motion.div>
);
})}
</div>
<p className="text-xs font-medium text-slate-600">{week.week}</p>
</div>
);
})}
</div>
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,19 @@
import { PerformanceTitle } from './PerformanceTitle';
import { OverviewStatsSection } from './OverviewStatsSection';
import { FunnelSection } from './FunnelSection';
import { TrendSection } from './TrendSection';
import { HeatmapSection } from './HeatmapSection';
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
import { TopContentSection } from './TopContentSection';
import { AIRecommendationSection } from './AIRecommendationSection';
export {
PerformanceTitle,
OverviewStatsSection,
FunnelSection,
TrendSection,
HeatmapSection,
ChannelPerformanceSection,
TopContentSection,
AIRecommendationSection,
};

View File

@ -32,18 +32,8 @@ const PAGE_FLOW: FlowStep[] = [
},
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
{
id: "distribute",
label: "콘텐츠 배포",
navigatePath: "/distribute",
isActive: (p) => p === "/distribute",
},
{
id: "performance",
label: "성과 관리",
navigatePath: "/performance",
isActive: (p) => p === "/performance",
},
{ id: "distribute", label: "콘텐츠 배포", navigatePath: "/distribute", isActive: (p) => p === "/distribute" },
{ id: "performance", label: "성과 관리", navigatePath: "/performance", isActive: (p) => p === "/performance" },
];
function flowIndexForPathname(pathname: string): number {

View File

@ -0,0 +1,11 @@
import { ChannelConnectTitle } from '@/features/channelconnect/ui';
import { ChannelConnectSection } from '@/features/channelconnect/ui';
export function ChannelConnect() {
return (
<div className="flex flex-col w-full">
<ChannelConnectTitle />
<ChannelConnectSection />
</div>
);
}

View File

@ -0,0 +1,11 @@
import { DistributionTitle } from '@/features/distribution/ui';
import { DistributionSection } from '@/features/distribution/ui';
export function Distribution() {
return (
<div className="flex flex-col w-full">
<DistributionTitle />
<DistributionSection />
</div>
);
}

23
src/pages/Performance.tsx Normal file
View File

@ -0,0 +1,23 @@
import { PerformanceTitle } from '@/features/performance/ui';
import { OverviewStatsSection } from '@/features/performance/ui';
import { FunnelSection } from '@/features/performance/ui';
import { TrendSection } from '@/features/performance/ui';
import { HeatmapSection } from '@/features/performance/ui';
import { ChannelPerformanceSection } from '@/features/performance/ui';
import { TopContentSection } from '@/features/performance/ui';
import { AIRecommendationSection } from '@/features/performance/ui';
export function Performance() {
return (
<div className="flex flex-col w-full">
<PerformanceTitle />
<OverviewStatsSection />
<FunnelSection />
<TrendSection />
<HeatmapSection />
<ChannelPerformanceSection />
<TopContentSection />
<AIRecommendationSection />
</div>
);
}