Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
dd1a1c3914 | |
|
|
5b22436c4e | |
|
|
992c232e16 | |
|
|
dfc04af69f | |
|
|
9fe5862e2b |
|
|
@ -12,6 +12,8 @@ dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|
@ -22,3 +24,5 @@ dist-ssr
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
zzz/
|
||||||
19
README.md
19
README.md
|
|
@ -21,6 +21,25 @@ src/
|
||||||
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
|
│ │ ├── content/ # 해당 도메인 전용 UI 텍스트·카피 (정적 콘텐츠)
|
||||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||||
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
│ │ └── ui/ # 해당 도메인 전용 UI 컴포넌트
|
||||||
|
│ ├── channelconnect/ # 채널 연결 도메인
|
||||||
|
│ │ ├── constants/ # 채널 목록 등 상수
|
||||||
|
│ │ ├── hooks/ # 연결 플로우용 훅
|
||||||
|
│ │ ├── store/ # 연결 채널·카운트 등 상태
|
||||||
|
│ │ ├── types/ # 도메인 타입
|
||||||
|
│ │ ├── utils/ # 채널 아이콘 매핑 등
|
||||||
|
│ │ └── ui/ # 타이틀·섹션 UI
|
||||||
|
│ ├── distribution/ # 콘텐츠 배포 도메인
|
||||||
|
│ │ ├── constants/ # 문구·옵션 상수
|
||||||
|
│ │ ├── hooks/ # 배포·예약 게시 훅
|
||||||
|
│ │ ├── store/ # 예약·선택 채널 등 상태
|
||||||
|
│ │ ├── types/ # 도메인 타입
|
||||||
|
│ │ └── ui/ # 채널 선택, 예약 게시, 미리보기 등
|
||||||
|
│ ├── performance/ # 성과 도메인
|
||||||
|
│ │ ├── constants/ # 지표·섹션 설정 상수
|
||||||
|
│ │ ├── hooks/ # 데이터 로딩·필터 훅
|
||||||
|
│ │ ├── store/ # 화면 전용 상태
|
||||||
|
│ │ ├── types/ # 지표·성과 타입
|
||||||
|
│ │ └── ui/ # 요약, 퍼널, 히트맵, 추천 등
|
||||||
│ ├── report/ # 리포트 도메인
|
│ ├── report/ # 리포트 도메인
|
||||||
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
|
│ │ ├── config/ # 섹션 ID·레이블 등 UI 설정값
|
||||||
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
│ │ ├── hooks/ # 해당 도메인 전용 커스텀 훅
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"motion": "^12.38.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
|
@ -65,6 +67,7 @@
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -1566,6 +1569,7 @@
|
||||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.21.3",
|
"@babel/core": "^7.21.3",
|
||||||
"@svgr/babel-preset": "8.1.0",
|
"@svgr/babel-preset": "8.1.0",
|
||||||
|
|
@ -2246,6 +2250,7 @@
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2256,6 +2261,7 @@
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2315,6 +2321,7 @@
|
||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.57.0",
|
"@typescript-eslint/scope-manager": "8.57.0",
|
||||||
"@typescript-eslint/types": "8.57.0",
|
"@typescript-eslint/types": "8.57.0",
|
||||||
|
|
@ -2592,6 +2599,7 @@
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2717,6 +2725,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -3142,6 +3151,7 @@
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -3453,6 +3463,33 @@
|
||||||
"node": ">= 6"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -3927,6 +3964,47 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4179,6 +4258,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -4250,6 +4330,7 @@
|
||||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
|
|
@ -4464,7 +4545,6 @@
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
|
|
@ -4486,6 +4566,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -4572,6 +4653,7 @@
|
||||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -4723,6 +4805,7 @@
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"motion": "^12.38.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
||||||
// pages
|
// pages
|
||||||
import { Home } from "@/pages/Home";
|
import { Home } from "@/pages/Home";
|
||||||
import { PlanPage } from "@/pages/Plan";
|
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";
|
import { ReportPage } from "@/pages/Report";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -14,6 +17,9 @@ function App() {
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
|
<Route path="channels" element={<ChannelConnect />} />
|
||||||
|
<Route path="distribute" element={<Distribution />} />
|
||||||
|
<Route path="performance" element={<Performance />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<MainSubNavLayout />}>
|
<Route element={<MainSubNavLayout />}>
|
||||||
<Route path="report/:id" element={<ReportPage />} />
|
<Route path="report/:id" element={<ReportPage />} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { ChannelConnectTitle } from './ChannelConnectTitle';
|
||||||
|
import { ChannelConnectSection } from './ChannelConnectSection';
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChannelConnectTitle,
|
||||||
|
ChannelConnectSection,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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' },
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { DistributionTitle } from './DistributionTitle';
|
||||||
|
import { DistributionSection } from './DistributionSection';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DistributionTitle,
|
||||||
|
DistributionSection,
|
||||||
|
};
|
||||||
|
|
@ -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 버튼 위치와 페이지 속도 개선 필요.' },
|
||||||
|
];
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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])),
|
||||||
|
}));
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -32,18 +32,8 @@ const PAGE_FLOW: FlowStep[] = [
|
||||||
},
|
},
|
||||||
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
||||||
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
||||||
{
|
{ id: "distribute", label: "콘텐츠 배포", navigatePath: "/distribute", isActive: (p) => p === "/distribute" },
|
||||||
id: "distribute",
|
{ id: "performance", label: "성과 관리", navigatePath: "/performance", isActive: (p) => p === "/performance" },
|
||||||
label: "콘텐츠 배포",
|
|
||||||
navigatePath: "/distribute",
|
|
||||||
isActive: (p) => p === "/distribute",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "performance",
|
|
||||||
label: "성과 관리",
|
|
||||||
navigatePath: "/performance",
|
|
||||||
isActive: (p) => p === "/performance",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function flowIndexForPathname(pathname: string): number {
|
function flowIndexForPathname(pathname: string): number {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue