Compare commits
No commits in common. "992c232e163133dcc317691255c23ad80900bb28" and "9fe5862e2bfb2b2c4f8b5b0310f9cafbd2b0147a" have entirely different histories.
992c232e16
...
9fe5862e2b
|
|
@ -1,14 +1,12 @@
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
// layouts
|
// layouts
|
||||||
import MainLayout from "@/layouts/MainLayout";
|
import MainLayout from "@/layouts/MainLayout";
|
||||||
import MainSubNavLayout from "@/layouts/MainSubNavLayout";
|
|
||||||
|
|
||||||
// pages
|
// pages
|
||||||
import { Home } from "@/pages/Home";
|
import { Home } from "@/pages/Home";
|
||||||
import { ChannelConnect } from "@/pages/ChannelConnect";
|
import { ChannelConnect } from "@/pages/ChannelConnect";
|
||||||
import { Distribution } from "@/pages/Distribution";
|
import { Distribution } from "@/pages/Distribution";
|
||||||
import { Performance } from "@/pages/Performance";
|
import { Performance } from "@/pages/Performance";
|
||||||
import { ReportPage } from "@/pages/Report";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
|
|
@ -20,9 +18,6 @@ function App() {
|
||||||
<Route path="distribute" element={<Distribution />} />
|
<Route path="distribute" element={<Distribution />} />
|
||||||
<Route path="performance" element={<Performance />} />
|
<Route path="performance" element={<Performance />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route element={<MainSubNavLayout />}>
|
|
||||||
<Route path="report/:id" element={<ReportPage />} />
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@
|
||||||
|
|
||||||
/* 브랜드 그라디언트 Primary 버튼 */
|
/* 브랜드 그라디언트 Primary 버튼 */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply cursor-pointer bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
@apply bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Typography Scale ────────────────────────────────────────────── */
|
/* ─── Typography Scale ────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 273 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 228 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 226 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="8" r="6" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M8.21 13.89 7 23l5-3 5 3-1.21-9.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,6 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 423 B |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="2" y="2" width="20" height="20" rx="5" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 329 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="m21 21-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 269 B |
|
|
@ -1,6 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 485 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 281 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 189 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 511 B |
|
|
@ -1,6 +0,0 @@
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 225 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M22 7 13.5 15.5 8.5 10.5 2 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
<path d="M16 7h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 345 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm11-4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 336 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<rect x="2" y="6" width="14" height="12" rx="2" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 392 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 429 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M7 17L17 7M17 7H9M17 7V15"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 271 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 294 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 312 B |
|
|
@ -1,3 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
||||||
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 238 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M12 3v12m0 0l4-4m-4 4L8 11M5 21h14"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 280 B |
|
|
@ -1,6 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 454 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 285 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path
|
|
||||||
d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4M12 17h.01"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 367 B |
|
|
@ -1,5 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
|
||||||
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 301 B |
|
|
@ -1,10 +0,0 @@
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 347 B |
|
|
@ -1,9 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<path
|
|
||||||
d="M7.9 20A9 9 0 1 0 4 16.1L2 22l5.9-2z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 282 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
|
||||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 293 B |
|
|
@ -1,45 +0,0 @@
|
||||||
import type { Severity } from "@/types/severity";
|
|
||||||
|
|
||||||
export type SeverityBadgeProps = {
|
|
||||||
severity: Severity;
|
|
||||||
label?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const config: Record<Severity, { className: string; defaultLabel: string }> = {
|
|
||||||
critical: {
|
|
||||||
className:
|
|
||||||
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]",
|
|
||||||
defaultLabel: "심각",
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
className:
|
|
||||||
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]",
|
|
||||||
defaultLabel: "주의",
|
|
||||||
},
|
|
||||||
good: {
|
|
||||||
className:
|
|
||||||
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]",
|
|
||||||
defaultLabel: "양호",
|
|
||||||
},
|
|
||||||
excellent: {
|
|
||||||
className:
|
|
||||||
"bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border-[var(--color-status-info-border)]",
|
|
||||||
defaultLabel: "우수",
|
|
||||||
},
|
|
||||||
unknown: {
|
|
||||||
className: "bg-neutral-10 text-neutral-80 border-neutral-20",
|
|
||||||
defaultLabel: "미확인",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
|
|
||||||
const { className, defaultLabel } = config[severity];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex items-center rounded-full label-12 font-medium px-3 py-1 border ${className}`}
|
|
||||||
>
|
|
||||||
{label ?? defaultLabel}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react";
|
|
||||||
import ChevronRightIcon from "@/assets/report/chevron-right.svg?react";
|
|
||||||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
|
||||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
|
||||||
import type { BrandInconsistency } from "@/types/brandConsistency";
|
|
||||||
|
|
||||||
export type BrandConsistencyMapProps = {
|
|
||||||
inconsistencies: BrandInconsistency[];
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BrandConsistencyMap({ inconsistencies, className = "" }: BrandConsistencyMapProps) {
|
|
||||||
const [expanded, setExpanded] = useState<number | null>(0);
|
|
||||||
|
|
||||||
if (inconsistencies.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`mt-8 animate-fade-in-up animation-delay-200 ${className}`.trim()}>
|
|
||||||
<h3 className="font-serif headline-20 text-navy-900 mb-2 break-keep">Brand Consistency Map</h3>
|
|
||||||
<p className="body-14 text-neutral-60 mb-5 break-keep">전 채널 브랜드 일관성 분석</p>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{inconsistencies.map((item, i) => {
|
|
||||||
const wrongCount = item.values.filter((v) => !v.isCorrect).length;
|
|
||||||
const isOpen = expanded === i;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.field}
|
|
||||||
className="rounded-2xl border border-neutral-20 bg-white card-shadow overflow-hidden"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded(isOpen ? null : i)}
|
|
||||||
className="w-full flex items-center justify-between gap-3 p-5 text-left hover:bg-neutral-10/80 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-navy-900 flex items-center justify-center text-white label-12-semibold shrink-0">
|
|
||||||
{wrongCount}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="title-14 text-navy-900 break-keep">{item.field}</p>
|
|
||||||
<p className="label-12 text-neutral-60 break-keep">{wrongCount}개 채널 불일치</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronRightIcon
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
className={`text-neutral-60 shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen ? (
|
|
||||||
<div className="px-5 pb-5 border-t border-neutral-20">
|
|
||||||
<div className="grid gap-2 mt-4 mb-4">
|
|
||||||
{item.values.map((v) => (
|
|
||||||
<div
|
|
||||||
key={v.channel}
|
|
||||||
className={`flex flex-wrap items-center justify-between gap-2 py-3 px-3 rounded-lg body-14 sm:flex-nowrap ${
|
|
||||||
v.isCorrect
|
|
||||||
? "bg-[var(--color-status-good-bg)]/60"
|
|
||||||
: "bg-[var(--color-status-critical-bg)]/60"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="font-medium text-neutral-80 shrink-0 min-w-[100px] break-keep">
|
|
||||||
{v.channel}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`flex-1 text-right min-w-0 break-keep ${
|
|
||||||
v.isCorrect
|
|
||||||
? "text-[var(--color-status-good-text)]"
|
|
||||||
: "text-[var(--color-status-critical-text)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{v.value}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto sm:ml-3 shrink-0">
|
|
||||||
{v.isCorrect ? (
|
|
||||||
<CheckCircleIcon
|
|
||||||
width={15}
|
|
||||||
height={15}
|
|
||||||
className="text-[var(--color-status-good-dot)]"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<XCircleIcon
|
|
||||||
width={15}
|
|
||||||
height={15}
|
|
||||||
className="text-[var(--color-status-critical-dot)]"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-[var(--color-status-warning-bg)] border border-[var(--color-status-warning-border)] p-4 mb-3">
|
|
||||||
<p className="label-12-semibold text-[var(--color-status-warning-text)] uppercase tracking-wide mb-1 break-keep">
|
|
||||||
<AlertCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
|
||||||
Impact
|
|
||||||
</p>
|
|
||||||
<p className="body-14 text-[var(--color-status-warning-text)] break-keep">{item.impact}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)] p-4">
|
|
||||||
<p className="label-12-semibold text-[var(--color-status-good-text)] uppercase tracking-wide mb-1 break-keep">
|
|
||||||
<CheckCircleIcon className="inline mr-1 align-text-bottom shrink-0" width={12} height={12} aria-hidden />
|
|
||||||
Recommendation
|
|
||||||
</p>
|
|
||||||
<p className="body-14 text-[var(--color-status-good-text)] break-keep">{item.recommendation}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { SeverityBadge } from "@/components/badge/SeverityBadge";
|
|
||||||
import { ScoreRing } from "@/components/rating/ScoreRing";
|
|
||||||
import type { Severity } from "@/types/severity";
|
|
||||||
|
|
||||||
export type ChannelScoreCardProps = {
|
|
||||||
channel: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
/** 아이콘·점수 링에 쓰는 브랜드/강조 색 (없으면 링은 점수대비 자동색) */
|
|
||||||
accentColor?: string;
|
|
||||||
score: number;
|
|
||||||
maxScore: number;
|
|
||||||
headline: string;
|
|
||||||
severity: Severity;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChannelScoreCard({
|
|
||||||
channel,
|
|
||||||
icon,
|
|
||||||
accentColor,
|
|
||||||
score,
|
|
||||||
maxScore,
|
|
||||||
headline,
|
|
||||||
severity,
|
|
||||||
className = "",
|
|
||||||
style,
|
|
||||||
}: ChannelScoreCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-4 text-center flex flex-col items-center gap-3 animate-fade-in-up ${className}`.trim()}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`w-10 h-10 rounded-xl bg-neutral-10 flex items-center justify-center shrink-0 [&_svg]:block ${accentColor ? "" : "text-neutral-60"}`}
|
|
||||||
style={accentColor ? { color: accentColor } : undefined}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<p className="body-14-medium text-navy-900 break-keep px-1">{channel}</p>
|
|
||||||
<ScoreRing score={score} maxScore={maxScore} size={60} color={accentColor} className="gap-1" />
|
|
||||||
<p className="label-12 text-neutral-60 line-clamp-2 leading-relaxed min-h-8 break-keep px-1">{headline}</p>
|
|
||||||
<SeverityBadge severity={severity} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
|
|
||||||
export type InfoStatCardProps = {
|
|
||||||
icon: ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InfoStatCard({ icon, label, value, className = "", style }: InfoStatCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-5 animate-fade-in-up ${className}`.trim()}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-neutral-10 flex items-center justify-center text-neutral-60">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="label-12 text-neutral-60 uppercase tracking-wide break-keep">{label}</p>
|
|
||||||
<p className="title-18 text-navy-900 mt-1 break-keep">{value}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import ArrowDownIcon from "@/assets/icons/arrow-down.svg?react";
|
|
||||||
import ArrowUpIcon from "@/assets/icons/arrow-up.svg?react";
|
|
||||||
import MinusIcon from "@/assets/icons/minus.svg?react";
|
|
||||||
|
|
||||||
export type MetricCardProps = {
|
|
||||||
label: string;
|
|
||||||
value: string | number;
|
|
||||||
subtext?: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
trend?: "up" | "down" | "neutral";
|
|
||||||
};
|
|
||||||
|
|
||||||
const trendConfig = {
|
|
||||||
up: { Icon: ArrowUpIcon, className: "text-violet-600" },
|
|
||||||
down: { Icon: ArrowDownIcon, className: "text-[var(--color-status-critical-text)]" },
|
|
||||||
neutral: { Icon: MinusIcon, className: "text-neutral-60" },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) {
|
|
||||||
const TrendGlyph = trend ? trendConfig[trend].Icon : null;
|
|
||||||
const trendColor = trend ? trendConfig[trend].className : "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-neutral-20 shadow-sm bg-white p-5 relative animate-fade-in-up">
|
|
||||||
{icon ? <div className="absolute top-4 right-4 text-neutral-40 [&_svg]:block">{icon}</div> : null}
|
|
||||||
<p className="body-14 text-neutral-60 mb-1 break-keep">{label}</p>
|
|
||||||
<div className="flex items-end gap-2 min-w-0">
|
|
||||||
<span className="text-3xl font-bold text-navy-900 break-keep">{value}</span>
|
|
||||||
{trend && TrendGlyph ? (
|
|
||||||
<span className={`mb-1 ${trendColor}`}>
|
|
||||||
<TrendGlyph width={18} height={18} aria-hidden />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{subtext ? <p className="label-12 text-neutral-60 mt-1 break-keep">{subtext}</p> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
|
||||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
|
||||||
|
|
||||||
export type PixelInstallCardProps = {
|
|
||||||
name: string;
|
|
||||||
installed: boolean;
|
|
||||||
details?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PixelInstallCard({ name, installed, details }: PixelInstallCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 rounded-xl p-3 min-w-0 ${
|
|
||||||
installed
|
|
||||||
? "bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)]"
|
|
||||||
: "bg-[var(--color-status-critical-bg)] border border-[var(--color-status-critical-border)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{installed ? (
|
|
||||||
<CheckCircleIcon width={16} height={16} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<XCircleIcon width={16} height={16} className="text-[var(--color-status-critical-dot)] shrink-0" aria-hidden />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p
|
|
||||||
className={`body-14-medium break-keep ${
|
|
||||||
installed ? "text-[var(--color-status-good-text)]" : "text-[var(--color-status-critical-text)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</p>
|
|
||||||
{details ? (
|
|
||||||
<p className="label-12 text-neutral-60 break-keep min-w-0">{details}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import EyeIcon from "@/assets/icons/eye.svg?react";
|
|
||||||
import { formatCompactNumber } from "@/lib/formatNumber";
|
|
||||||
|
|
||||||
export type TopVideoCardProps = {
|
|
||||||
title: string;
|
|
||||||
views: number;
|
|
||||||
uploadedAgo: string;
|
|
||||||
type: "Short" | "Long";
|
|
||||||
duration?: string;
|
|
||||||
className?: string;
|
|
||||||
style?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TopVideoCard({
|
|
||||||
title,
|
|
||||||
views,
|
|
||||||
uploadedAgo,
|
|
||||||
type,
|
|
||||||
duration,
|
|
||||||
className = "",
|
|
||||||
style,
|
|
||||||
}: TopVideoCardProps) {
|
|
||||||
const typeClass =
|
|
||||||
type === "Short"
|
|
||||||
? "bg-lavender-100 text-violet-700"
|
|
||||||
: "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-xl bg-white border border-neutral-20 shadow-sm p-4 min-w-[250px] shrink-0 animate-fade-in-up ${className}`.trim()}
|
|
||||||
style={style}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
||||||
<span className={`label-12 font-medium px-2 py-1 rounded-full break-keep ${typeClass}`}>{type}</span>
|
|
||||||
{duration ? <span className="label-12 text-neutral-60 break-keep">{duration}</span> : null}
|
|
||||||
<span className="label-12 text-neutral-60 break-keep">{uploadedAgo}</span>
|
|
||||||
</div>
|
|
||||||
<p className="body-14-medium text-navy-900 line-clamp-2 mb-2 break-keep">{title}</p>
|
|
||||||
<div className="flex items-center gap-1 body-14 text-neutral-70 min-w-0">
|
|
||||||
<EyeIcon width={14} height={14} className="text-neutral-60 shrink-0" aria-hidden />
|
|
||||||
<span className="title-14 break-keep">{formatCompactNumber(views)}</span>
|
|
||||||
<span className="text-neutral-60 break-keep">views</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
|
|
||||||
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
|
||||||
import HelpCircleIcon from "@/assets/report/help-circle.svg?react";
|
|
||||||
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
|
||||||
import type { OtherChannelStatus } from "@/types/otherChannels";
|
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
|
||||||
|
|
||||||
export type OtherChannelRowProps = {
|
|
||||||
name: string;
|
|
||||||
details: string;
|
|
||||||
status: OtherChannelStatus;
|
|
||||||
url?: string;
|
|
||||||
animationDelayMs?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusMeta: Record<
|
|
||||||
OtherChannelStatus,
|
|
||||||
{ Icon: typeof CheckCircleIcon; iconClass: string; labelClass: string; label: string }
|
|
||||||
> = {
|
|
||||||
active: {
|
|
||||||
Icon: CheckCircleIcon,
|
|
||||||
iconClass: "text-[var(--color-status-good-dot)]",
|
|
||||||
labelClass: "text-[var(--color-status-good-text)]",
|
|
||||||
label: "활성",
|
|
||||||
},
|
|
||||||
inactive: {
|
|
||||||
Icon: XCircleIcon,
|
|
||||||
iconClass: "text-[var(--color-status-critical-dot)]",
|
|
||||||
labelClass: "text-[var(--color-status-critical-text)]",
|
|
||||||
label: "비활성",
|
|
||||||
},
|
|
||||||
unknown: {
|
|
||||||
Icon: HelpCircleIcon,
|
|
||||||
iconClass: "text-neutral-60",
|
|
||||||
labelClass: "text-neutral-60",
|
|
||||||
label: "미확인",
|
|
||||||
},
|
|
||||||
not_found: {
|
|
||||||
Icon: XCircleIcon,
|
|
||||||
iconClass: "text-[var(--color-status-critical-dot)]",
|
|
||||||
labelClass: "text-[var(--color-status-critical-text)]",
|
|
||||||
label: "미발견",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OtherChannelRow({ name, details, status, url, animationDelayMs = 0 }: OtherChannelRowProps) {
|
|
||||||
const meta = statusMeta[status];
|
|
||||||
const { Icon } = meta;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-4 px-6 py-4 animate-fade-in-left"
|
|
||||||
style={{ animationDelay: `${animationDelayMs}ms` }}
|
|
||||||
>
|
|
||||||
<Icon width={20} height={20} className={`shrink-0 ${meta.iconClass}`} aria-hidden />
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="body-14-medium text-navy-900 break-keep">{name}</p>
|
|
||||||
<p className="body-14 text-neutral-60 break-keep">{details}</p>
|
|
||||||
</div>
|
|
||||||
<span className={`label-12 font-medium shrink-0 break-keep ${meta.labelClass}`}>{meta.label}</span>
|
|
||||||
{url ? (
|
|
||||||
<a
|
|
||||||
href={safeUrl(url)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-violet-700 hover:underline shrink-0 cursor-pointer [&_svg]:block"
|
|
||||||
aria-label={`${name} 외부 링크`}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon width={16} height={16} aria-hidden />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
export type TagChipListProps = {
|
|
||||||
tags: string[];
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
/** 기본 `animation-delay-400`. 빈 문자열이면 지연 없음 */
|
|
||||||
entranceDelayClass?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TagChipList({
|
|
||||||
tags,
|
|
||||||
title,
|
|
||||||
className = "",
|
|
||||||
entranceDelayClass = "animation-delay-400",
|
|
||||||
}: TagChipListProps) {
|
|
||||||
if (!tags.length) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`animate-fade-in-up ${entranceDelayClass} ${className}`.trim()}>
|
|
||||||
{title ? <p className="body-14-medium text-neutral-80 mb-3 break-keep">{title}</p> : null}
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full bg-white/60 backdrop-blur-sm border border-neutral-20 px-3 py-1 body-14-medium text-neutral-80 break-keep"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
export type ComparisonRowProps = {
|
|
||||||
area: string;
|
|
||||||
asIs: string;
|
|
||||||
toBe: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-[minmax(0,120px)_1fr_1fr] gap-3 items-start py-4 border-b border-neutral-20 last:border-0">
|
|
||||||
<span className="body-14-medium text-neutral-80 pt-0 md:pt-3 break-keep">{area}</span>
|
|
||||||
<div className="rounded-lg bg-[var(--color-status-critical-bg)]/50 p-3 body-14 text-neutral-70 break-keep">
|
|
||||||
{asIs}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-[var(--color-status-good-bg)]/50 p-3 body-14-medium text-neutral-80 break-keep">
|
|
||||||
{toBe}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { SeverityBadge } from "@/components/badge/SeverityBadge";
|
|
||||||
import type { Severity } from "@/types/severity";
|
|
||||||
|
|
||||||
export type DiagnosisRowProps = {
|
|
||||||
category: string;
|
|
||||||
detail: string;
|
|
||||||
severity: Severity;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DiagnosisRow({ category, detail, severity }: DiagnosisRowProps) {
|
|
||||||
return (
|
|
||||||
<div className="py-4 border-b border-neutral-20 last:border-b-0">
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
|
||||||
<span className="title-14 text-navy-900 shrink-0 sm:w-32 break-keep">{category}</span>
|
|
||||||
<p className="flex-1 body-14 text-neutral-70 min-w-0 break-keep">{detail}</p>
|
|
||||||
<div className="shrink-0 sm:self-start">
|
|
||||||
<SeverityBadge severity={severity} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
export { ComparisonRow, type ComparisonRowProps } from "@/components/compare/ComparisonRow";
|
|
||||||
export { BrandConsistencyMap, type BrandConsistencyMapProps } from "@/components/brand/BrandConsistencyMap";
|
|
||||||
export { OtherChannelRow, type OtherChannelRowProps } from "@/components/channel/OtherChannelRow";
|
|
||||||
export { SeverityBadge, type SeverityBadgeProps } from "@/components/badge/SeverityBadge";
|
|
||||||
export { ChannelScoreCard, type ChannelScoreCardProps } from "@/components/card/ChannelScoreCard";
|
|
||||||
export { InfoStatCard, type InfoStatCardProps } from "@/components/card/InfoStatCard";
|
|
||||||
export { MetricCard, type MetricCardProps } from "@/components/card/MetricCard";
|
|
||||||
export { PixelInstallCard, type PixelInstallCardProps } from "@/components/card/PixelInstallCard";
|
|
||||||
export { TopVideoCard, type TopVideoCardProps } from "@/components/card/TopVideoCard";
|
|
||||||
export { TagChipList, type TagChipListProps } from "@/components/chip/TagChipList";
|
|
||||||
export { DiagnosisRow, type DiagnosisRowProps } from "@/components/diagnosis/DiagnosisRow";
|
|
||||||
export { ConsolidationCallout, type ConsolidationCalloutProps } from "@/components/panel/ConsolidationCallout";
|
|
||||||
export { HighlightPanel, type HighlightPanelProps } from "@/components/panel/HighlightPanel";
|
|
||||||
export { ScoreRing, type ScoreRingProps } from "@/components/rating/ScoreRing";
|
|
||||||
export { StarRatingDisplay, type StarRatingDisplayProps } from "@/components/rating/StarRatingDisplay";
|
|
||||||
export { PageSection, type PageSectionProps } from "@/components/section/PageSection";
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type ConsolidationCalloutProps = {
|
|
||||||
title: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
children: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 통합·전략 권고 등 강조 CTA 블록 — 리포트 채널 섹션 공통 */
|
|
||||||
export function ConsolidationCallout({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
children,
|
|
||||||
className = "",
|
|
||||||
}: ConsolidationCalloutProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`mt-8 rounded-2xl bg-gradient-to-r from-violet-700 to-navy-950 p-6 md:p-8 text-white animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{icon ? (
|
|
||||||
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 text-white [&_svg]:block">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="font-serif headline-24 mb-2 break-keep">{title}</h4>
|
|
||||||
<div className="body-14 text-lavender-200 leading-relaxed break-keep">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type HighlightPanelProps = {
|
|
||||||
title: string;
|
|
||||||
icon?: ReactNode;
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function HighlightPanel({ title, icon, className = "", children }: HighlightPanelProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-2xl bg-gradient-to-r from-violet-700/5 to-navy-950/5 border border-lavender-200 p-6 animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
{icon ? <span className="text-violet-700 shrink-0 [&_svg]:block">{icon}</span> : null}
|
|
||||||
<h3 className="font-serif headline-20 text-navy-900">{title}</h3>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export type ScoreRingProps = {
|
|
||||||
score: number;
|
|
||||||
maxScore?: number;
|
|
||||||
size?: number;
|
|
||||||
label?: string;
|
|
||||||
/** 링 색 (브랜드 등). 없으면 점수 구간별 자동 색 */
|
|
||||||
color?: string;
|
|
||||||
className?: string;
|
|
||||||
scoreClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function scoreStrokeColor(score: number, maxScore: number): string {
|
|
||||||
const pct = (score / maxScore) * 100;
|
|
||||||
if (pct <= 40) return "#D4889A";
|
|
||||||
if (pct <= 60) return "#7A84D4";
|
|
||||||
if (pct <= 80) return "#9B8AD4";
|
|
||||||
return "#6C5CE7";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScoreRing({
|
|
||||||
score,
|
|
||||||
maxScore = 100,
|
|
||||||
size = 120,
|
|
||||||
label,
|
|
||||||
color,
|
|
||||||
className = "",
|
|
||||||
scoreClassName,
|
|
||||||
}: ScoreRingProps) {
|
|
||||||
const strokeWidth = size <= 72 ? 5 : 8;
|
|
||||||
const radius = (size - strokeWidth) / 2;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const progress = Math.min(score / maxScore, 1);
|
|
||||||
const targetOffset = circumference * (1 - progress);
|
|
||||||
const resolvedColor = color ?? scoreStrokeColor(score, maxScore);
|
|
||||||
|
|
||||||
const [dashOffset, setDashOffset] = useState(circumference);
|
|
||||||
|
|
||||||
const defaultScoreClass =
|
|
||||||
size <= 72 ? "text-sm font-bold text-navy-900" : "text-2xl font-bold text-navy-900";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDashOffset(circumference);
|
|
||||||
const id = requestAnimationFrame(() => setDashOffset(targetOffset));
|
|
||||||
return () => cancelAnimationFrame(id);
|
|
||||||
}, [circumference, targetOffset]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col items-center gap-2 ${className}`.trim()}>
|
|
||||||
<div className="relative" style={{ width: size, height: size }}>
|
|
||||||
<svg
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
|
||||||
className="-rotate-90"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx={size / 2}
|
|
||||||
cy={size / 2}
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-neutral-20"
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={size / 2}
|
|
||||||
cy={size / 2}
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke={resolvedColor}
|
|
||||||
strokeWidth={strokeWidth}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={dashOffset}
|
|
||||||
style={{ transition: "stroke-dashoffset 1s ease-out" }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<span className={scoreClassName ?? defaultScoreClass}>{score}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{label ? <span className="body-14 text-neutral-60 text-center">{label}</span> : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
import StarIcon from "@/assets/icons/star.svg?react";
|
|
||||||
import { formatCompactNumber } from "@/lib/formatNumber";
|
|
||||||
|
|
||||||
export type StarRatingDisplayProps = {
|
|
||||||
rating: number;
|
|
||||||
maxStars?: number;
|
|
||||||
reviewCount?: number;
|
|
||||||
formatCount?: (n: number) => string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function StarRatingDisplay({
|
|
||||||
rating,
|
|
||||||
maxStars = 5,
|
|
||||||
reviewCount,
|
|
||||||
formatCount = formatCompactNumber,
|
|
||||||
}: StarRatingDisplayProps) {
|
|
||||||
const filled = Math.min(maxStars, Math.max(0, Math.round(rating)));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
|
||||||
<div className="flex items-center gap-0.5">
|
|
||||||
{Array.from({ length: maxStars }, (_, i) => (
|
|
||||||
<StarIcon
|
|
||||||
key={i}
|
|
||||||
className={i < filled ? "text-violet-700" : "text-neutral-20"}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<span className="body-14-medium text-navy-900 ml-1">{rating}</span>
|
|
||||||
</div>
|
|
||||||
{reviewCount != null ? (
|
|
||||||
<span className="body-14 text-neutral-60">리뷰 {formatCount(reviewCount)}건</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
export type PageSectionProps = {
|
|
||||||
id?: string;
|
|
||||||
title: string;
|
|
||||||
subtitle?: string;
|
|
||||||
dark?: boolean;
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PageSection({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
dark = false,
|
|
||||||
className = "",
|
|
||||||
children,
|
|
||||||
}: PageSectionProps) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
id={id}
|
|
||||||
className={[
|
|
||||||
"py-16 md:py-20 px-6 scroll-mt-36 animate-fade-in-up",
|
|
||||||
dark ? "bg-navy-900 text-white relative overflow-hidden" : "bg-white",
|
|
||||||
className,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")}
|
|
||||||
>
|
|
||||||
{dark ? (
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.15),transparent_60%)] pointer-events-none"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="relative max-w-7xl mx-auto">
|
|
||||||
<header className="mb-10">
|
|
||||||
<h2
|
|
||||||
className={
|
|
||||||
dark
|
|
||||||
? "font-serif text-3xl md:display-36 font-bold mb-3 bg-gradient-to-r from-lavender-200 to-marketing-ice bg-clip-text text-transparent"
|
|
||||||
: "font-serif text-3xl md:display-36 font-bold mb-3 text-gradient"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
{subtitle ? (
|
|
||||||
<p className={dark ? "body-18 text-lavender-200 break-keep" : "body-18 text-neutral-70 break-keep"}>
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</header>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,18 +15,6 @@ export const CHANNELS: ChannelDef[] = [
|
||||||
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
|
{ 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',
|
id: 'instagram_kr',
|
||||||
name: 'Instagram KR',
|
name: 'Instagram KR',
|
||||||
|
|
@ -100,6 +88,18 @@ export const CHANNELS: ChannelDef[] = [
|
||||||
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
|
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tiktok',
|
||||||
|
name: 'TikTok',
|
||||||
|
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
|
||||||
|
iconKey: 'tiktok',
|
||||||
|
brandColor: '#000000',
|
||||||
|
bgColor: '#F5F5F5',
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
fields: [
|
||||||
|
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'gangnamunni',
|
id: 'gangnamunni',
|
||||||
name: '강남언니',
|
name: '강남언니',
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import type { ChannelDef, ChannelState } from '../types';
|
import type { ChannelDef, ChannelState } from '../types';
|
||||||
import { CHANNELS } from '../constants/channels';
|
import { CHANNELS } from '../constants/channels';
|
||||||
import { useChannelConnectStore } from '../store/channelConnectStore';
|
import { useChannelConnect } from '../hooks/useChannelConnect';
|
||||||
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
|
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
|
||||||
|
import { ChannelConnectTitle } from './ChannelConnectTitle';
|
||||||
|
|
||||||
interface ChannelCardProps {
|
interface ChannelCardProps {
|
||||||
ch: ChannelDef;
|
ch: ChannelDef;
|
||||||
|
|
@ -141,15 +142,17 @@ export function ChannelConnectSection() {
|
||||||
const {
|
const {
|
||||||
channels,
|
channels,
|
||||||
expandedId,
|
expandedId,
|
||||||
|
connectedCount,
|
||||||
setExpandedId,
|
setExpandedId,
|
||||||
handleFieldChange,
|
handleFieldChange,
|
||||||
handleConnect,
|
handleConnect,
|
||||||
handleDisconnect,
|
handleDisconnect,
|
||||||
} = useChannelConnectStore();
|
} = useChannelConnect();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-12 px-6">
|
<div>
|
||||||
<div className="max-w-5xl mx-auto">
|
<ChannelConnectTitle connectedCount={connectedCount} />
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{CHANNELS.map(ch => (
|
{CHANNELS.map(ch => (
|
||||||
<ChannelCard
|
<ChannelCard
|
||||||
|
|
@ -165,6 +168,6 @@ export function ChannelConnectSection() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { CHANNELS } from '../constants/channels';
|
import { CHANNELS } from '../constants/channels';
|
||||||
import { useChannelConnectStore } from '../store/channelConnectStore';
|
|
||||||
|
|
||||||
export function ChannelConnectTitle() {
|
interface ChannelConnectTitleProps {
|
||||||
const { connectedCount } = useChannelConnectStore();
|
connectedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelConnectTitle({ connectedCount }: ChannelConnectTitleProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ export const MOCK_CONTENT: MockContent = {
|
||||||
|
|
||||||
export const INITIAL_CHANNELS: ChannelTarget[] = [
|
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: '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 Reels', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (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: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
|
||||||
|
{ id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
|
||||||
{ id: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
|
{ id: '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' },
|
{ id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
|
||||||
|
{ id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, selected: false, status: 'ready', format: 'Video Post' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export function useDistribution() {
|
||||||
const [channels, setChannels] = useState(INITIAL_CHANNELS);
|
const [channels, setChannels] = useState(INITIAL_CHANNELS);
|
||||||
const [title, setTitle] = useState(MOCK_CONTENT.title);
|
const [title, setTitle] = useState(MOCK_CONTENT.title);
|
||||||
const [description, setDescription] = useState(MOCK_CONTENT.description);
|
const [description, setDescription] = useState(MOCK_CONTENT.description);
|
||||||
const [tags] = useState(MOCK_CONTENT.tags);
|
const [tags, setTags] = useState(MOCK_CONTENT.tags);
|
||||||
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
|
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
|
||||||
const [scheduleDate, setScheduleDate] = useState('');
|
const [scheduleDate, setScheduleDate] = useState('');
|
||||||
const [scheduleHour, setScheduleHour] = useState(9);
|
const [scheduleHour, setScheduleHour] = useState(9);
|
||||||
|
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { useDistributionStore } from '../store/distributionStore';
|
import type { ChannelTarget } from '../types';
|
||||||
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
|
|
||||||
|
|
||||||
export function ChannelSelectSection() {
|
interface ChannelSelectSectionProps {
|
||||||
const { channels, toggleChannel } = useDistributionStore();
|
channels: ChannelTarget[];
|
||||||
const { channels: connectedChannels } = useChannelConnectStore();
|
toggleChannel: (id: string) => void;
|
||||||
const mergedChannels = channels.map(ch => ({
|
}
|
||||||
...ch,
|
|
||||||
connected: connectedChannels[ch.id]?.status === 'connected',
|
export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) {
|
||||||
}));
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 채널 선택</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 채널 선택</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{mergedChannels.map(ch => {
|
{channels.map(ch => {
|
||||||
const Icon = ch.icon;
|
const Icon = ch.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -31,7 +29,9 @@ export function ChannelSelectSection() {
|
||||||
onClick={() => toggleChannel(ch.id)}
|
onClick={() => toggleChannel(ch.id)}
|
||||||
disabled={!ch.connected}
|
disabled={!ch.connected}
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
|
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'
|
ch.selected && ch.connected
|
||||||
|
? 'border-[#6C5CE7] bg-[#6C5CE7]'
|
||||||
|
: 'border-slate-300 bg-white'
|
||||||
} disabled:cursor-not-allowed`}
|
} disabled:cursor-not-allowed`}
|
||||||
>
|
>
|
||||||
{ch.selected && ch.connected && (
|
{ch.selected && ch.connected && (
|
||||||
|
|
@ -40,20 +40,30 @@ export function ChannelSelectSection() {
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: ch.bgColor }}>
|
|
||||||
|
<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 }} />
|
<Icon size={20} style={{ color: ch.brandColor }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
|
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
|
||||||
{!ch.connected && (
|
{!ch.connected && (
|
||||||
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">미연결</span>
|
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">
|
||||||
|
미연결
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">{ch.format}</p>
|
<p className="text-xs text-slate-400">{ch.format}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<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 === 'publishing' && (
|
||||||
|
<div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||||
|
)}
|
||||||
{ch.status === 'published' && (
|
{ch.status === 'published' && (
|
||||||
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
|
<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">
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
import { VideoFilled } from '@/components/icons/FilledIcons';
|
import { VideoFilled } from '@/components/icons/FilledIcons';
|
||||||
import { MOCK_CONTENT } from '../constants/distribution';
|
import { MOCK_CONTENT } from '../constants/distribution';
|
||||||
import { useDistributionStore } from '../store/distributionStore';
|
|
||||||
|
|
||||||
export function ContentPreviewSection() {
|
interface ContentPreviewSectionProps {
|
||||||
const { title, setTitle, description, setDescription, tags } = useDistributionStore();
|
title: string;
|
||||||
|
setTitle: (v: string) => void;
|
||||||
|
description: string;
|
||||||
|
setDescription: (v: string) => void;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentPreviewSection({ title, setTitle, description, setDescription, tags }: ContentPreviewSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="lg:col-span-1">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">콘텐츠</h3>
|
<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">
|
<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" />
|
<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-500">{MOCK_CONTENT.aspectRatio}</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
|
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -20,6 +28,7 @@ export function ContentPreviewSection() {
|
||||||
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"
|
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>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -29,11 +38,15 @@ export function ContentPreviewSection() {
|
||||||
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"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span key={tag} className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]"
|
||||||
|
>
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1,19 +1,63 @@
|
||||||
import { ContentPreviewSection } from './ContentPreview';
|
import { useDistribution } from '../hooks/useDistribution';
|
||||||
import { ChannelSelectSection } from './ChannelSelect';
|
import { DistributionTitle } from './DistributionTitle';
|
||||||
import { SchedulePublishSection } from './SchedulePublish';
|
import { ContentPreviewSection } from './ContentPreviewSection';
|
||||||
|
import { ChannelSelectSection } from './ChannelSelectSection';
|
||||||
|
import { SchedulePublishSection } from './SchedulePublishSection';
|
||||||
|
|
||||||
export function DistributionSection() {
|
export function DistributionSection() {
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
title, setTitle,
|
||||||
|
description, setDescription,
|
||||||
|
tags,
|
||||||
|
scheduleMode, setScheduleMode,
|
||||||
|
scheduleDate, setScheduleDate,
|
||||||
|
scheduleHour, setScheduleHour,
|
||||||
|
scheduleMinute, setScheduleMinute,
|
||||||
|
schedulePeriod, setSchedulePeriod,
|
||||||
|
isPublishing,
|
||||||
|
selectedChannels,
|
||||||
|
allPublished,
|
||||||
|
toggleChannel,
|
||||||
|
handlePublish,
|
||||||
|
} = useDistribution();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div>
|
||||||
<div className="max-w-5xl mx-auto w-full">
|
<DistributionTitle />
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<ContentPreviewSection />
|
<ContentPreviewSection
|
||||||
<div className="lg:col-span-2 flex flex-col gap-8">
|
title={title}
|
||||||
<ChannelSelectSection />
|
setTitle={setTitle}
|
||||||
<SchedulePublishSection />
|
description={description}
|
||||||
|
setDescription={setDescription}
|
||||||
|
tags={tags}
|
||||||
|
/>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<ChannelSelectSection
|
||||||
|
channels={channels}
|
||||||
|
toggleChannel={toggleChannel}
|
||||||
|
/>
|
||||||
|
<SchedulePublishSection
|
||||||
|
scheduleMode={scheduleMode}
|
||||||
|
setScheduleMode={setScheduleMode}
|
||||||
|
scheduleDate={scheduleDate}
|
||||||
|
setScheduleDate={setScheduleDate}
|
||||||
|
scheduleHour={scheduleHour}
|
||||||
|
setScheduleHour={setScheduleHour}
|
||||||
|
scheduleMinute={scheduleMinute}
|
||||||
|
setScheduleMinute={setScheduleMinute}
|
||||||
|
schedulePeriod={schedulePeriod}
|
||||||
|
setSchedulePeriod={setSchedulePeriod}
|
||||||
|
isPublishing={isPublishing}
|
||||||
|
selectedChannels={selectedChannels}
|
||||||
|
allPublished={allPublished}
|
||||||
|
handlePublish={handlePublish}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { ShareFilled } from '@/components/icons/FilledIcons';
|
import { ShareFilled } from '@/components/icons/FilledIcons';
|
||||||
import { useDistributionStore } from '../store/distributionStore';
|
import type { ChannelTarget } from '../types';
|
||||||
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
|
|
||||||
|
|
||||||
export function SchedulePublishSection() {
|
interface SchedulePublishSectionProps {
|
||||||
const {
|
scheduleMode: 'now' | 'scheduled';
|
||||||
channels,
|
setScheduleMode: (v: 'now' | 'scheduled') => void;
|
||||||
scheduleMode, setScheduleMode,
|
scheduleDate: string;
|
||||||
scheduleDate, setScheduleDate,
|
setScheduleDate: (v: string) => void;
|
||||||
scheduleHour, setScheduleHour,
|
scheduleHour: number;
|
||||||
scheduleMinute, setScheduleMinute,
|
setScheduleHour: (fn: (h: number) => number) => void;
|
||||||
schedulePeriod, setSchedulePeriod,
|
scheduleMinute: number;
|
||||||
isPublishing,
|
setScheduleMinute: (fn: (m: number) => number) => void;
|
||||||
handlePublish,
|
schedulePeriod: 'AM' | 'PM';
|
||||||
} = useDistributionStore();
|
setSchedulePeriod: (v: 'AM' | 'PM') => void;
|
||||||
const { channels: connectedChannels } = useChannelConnectStore();
|
isPublishing: boolean;
|
||||||
const mergedChannels = channels.map(ch => ({
|
selectedChannels: ChannelTarget[];
|
||||||
...ch,
|
allPublished: boolean;
|
||||||
connected: connectedChannels[ch.id]?.status === 'connected',
|
handlePublish: () => void;
|
||||||
}));
|
}
|
||||||
const selectedChannels = mergedChannels.filter(c => c.connected && c.selected);
|
|
||||||
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
|
export function SchedulePublishSection({
|
||||||
|
scheduleMode, setScheduleMode,
|
||||||
|
scheduleDate, setScheduleDate,
|
||||||
|
scheduleHour, setScheduleHour,
|
||||||
|
scheduleMinute, setScheduleMinute,
|
||||||
|
schedulePeriod, setSchedulePeriod,
|
||||||
|
isPublishing,
|
||||||
|
selectedChannels,
|
||||||
|
allPublished,
|
||||||
|
handlePublish,
|
||||||
|
}: SchedulePublishSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 시간</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 시간</h3>
|
||||||
|
|
@ -54,32 +63,48 @@ export function SchedulePublishSection() {
|
||||||
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"
|
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-600 mb-2 block">시간</label>
|
<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-3">
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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>
|
<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>
|
</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]">
|
<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')}
|
{String(scheduleHour).padStart(2, '0')}
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xl font-bold text-slate-300">:</span>
|
<span className="text-xl font-bold text-slate-300">:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<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">
|
<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>
|
<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>
|
</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]">
|
<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')}
|
{String(scheduleMinute).padStart(2, '0')}
|
||||||
</div>
|
</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">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
||||||
{(['AM', 'PM'] as const).map(p => (
|
{(['AM', 'PM'] as const).map(p => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -102,7 +127,7 @@ export function SchedulePublishSection() {
|
||||||
|
|
||||||
{!allPublished ? (
|
{!allPublished ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePublish(selectedChannels.map(c => c.id))}
|
onClick={handlePublish}
|
||||||
disabled={selectedChannels.length === 0 || isPublishing}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|
@ -130,7 +155,9 @@ export function SchedulePublishSection() {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-[#0A1128] mb-1">배포 완료</p>
|
<p className="text-lg font-semibold text-[#0A1128] mb-1">배포 완료</p>
|
||||||
<p className="text-sm text-[#4A3A7C]">{selectedChannels.length}개 채널에 성공적으로 배포되었습니다</p>
|
<p className="text-sm text-[#4A3A7C]">
|
||||||
|
{selectedChannels.length}개 채널에 성공적으로 배포되었습니다
|
||||||
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { DistributionTitle } from './DistributionTitle';
|
import { DistributionTitle } from './DistributionTitle';
|
||||||
|
import { ContentPreviewSection } from './ContentPreviewSection';
|
||||||
|
import { ChannelSelectSection } from './ChannelSelectSection';
|
||||||
|
import { SchedulePublishSection } from './SchedulePublishSection';
|
||||||
import { DistributionSection } from './DistributionSection';
|
import { DistributionSection } from './DistributionSection';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DistributionTitle,
|
DistributionTitle,
|
||||||
|
ContentPreviewSection,
|
||||||
|
ChannelSelectSection,
|
||||||
|
SchedulePublishSection,
|
||||||
DistributionSection,
|
DistributionSection,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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])),
|
|
||||||
}));
|
|
||||||
|
|
@ -2,20 +2,16 @@ import { AI_RECOMMENDATIONS } from '../constants/performance';
|
||||||
|
|
||||||
export function AIRecommendationSection() {
|
export function AIRecommendationSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
|
||||||
<div className="max-w-6xl mx-auto">
|
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI 개선 추천</h3>
|
||||||
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI 개선 추천</h3>
|
{AI_RECOMMENDATIONS.map((rec, i) => (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
|
||||||
{AI_RECOMMENDATIONS.map((rec, i) => (
|
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
|
||||||
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
|
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
|
||||||
<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>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,47 +17,45 @@ function MetricCell({ label, value, delta }: { label: string; value: string; del
|
||||||
|
|
||||||
export function ChannelPerformanceSection() {
|
export function ChannelPerformanceSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="mb-10">
|
||||||
<div className="max-w-6xl mx-auto">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">채널별 성과</h3>
|
||||||
<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">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
{CHANNELS.map((ch, i) => {
|
||||||
{CHANNELS.map((ch, i) => {
|
const Icon = ch.icon;
|
||||||
const Icon = ch.icon;
|
return (
|
||||||
return (
|
<motion.div
|
||||||
<motion.div
|
key={ch.id}
|
||||||
key={ch.id}
|
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
||||||
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 }}
|
||||||
initial={{ opacity: 0, y: 15 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
transition={{ duration: 0.3, delay: i * 0.08 }}
|
||||||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
>
|
||||||
>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<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 }}>
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
|
<Icon size={20} style={{ color: ch.brandColor }} />
|
||||||
<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>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="flex-1">
|
||||||
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
|
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
|
||||||
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
|
<p className="text-xs text-slate-400">{ch.posts}개 콘텐츠</p>
|
||||||
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
|
|
||||||
</div>
|
</div>
|
||||||
</motion.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]' :
|
||||||
</div>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
|
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
|
||||||
import { usePerformanceStore } from '../store/performanceStore';
|
|
||||||
|
|
||||||
export function FunnelSection() {
|
export function FunnelSection({ funnelMax }: { funnelMax: number }) {
|
||||||
const { funnelMax } = usePerformanceStore();
|
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||||
<div className="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 className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
||||||
|
|
@ -68,8 +64,6 @@ export function FunnelSection() {
|
||||||
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
|
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
|
||||||
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
|
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,59 +11,55 @@ function heatmapColor(value: number): string {
|
||||||
|
|
||||||
export function HeatmapSection() {
|
export function HeatmapSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
||||||
<div>
|
<p className="text-xs text-slate-500 mt-1">요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음</p>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-1">요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음</p>
|
<div className="flex items-center gap-1">
|
||||||
</div>
|
<span className="text-xs text-slate-400">낮음</span>
|
||||||
<div className="flex items-center gap-1">
|
{[1, 3, 5, 7, 9].map(v => (
|
||||||
<span className="text-xs text-slate-400">낮음</span>
|
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
|
||||||
{[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>
|
||||||
))}
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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-105 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { MetricCard } from '@/components/card/MetricCard';
|
import { motion } from 'motion/react';
|
||||||
import { OVERVIEW_STATS } from '../constants/performance';
|
import { OVERVIEW_STATS } from '../constants/performance';
|
||||||
|
|
||||||
export function OverviewStatsSection() {
|
export function OverviewStatsSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
|
||||||
<div className="max-w-6xl mx-auto">
|
{OVERVIEW_STATS.map((stat, i) => (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
<motion.div
|
||||||
{OVERVIEW_STATS.map((stat) => (
|
key={stat.label}
|
||||||
<MetricCard
|
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
|
||||||
key={stat.label}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
label={stat.label}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
value={stat.value}
|
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||||||
subtext={stat.delta}
|
>
|
||||||
trend={stat.positive ? 'up' : 'down'}
|
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
|
||||||
/>
|
<p className="text-xl font-bold text-[#0A1128]">{stat.value}</p>
|
||||||
))}
|
<p className={`text-xs font-medium mt-1 ${stat.positive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>{stat.delta}</p>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
))}
|
||||||
</section>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { usePerformance } from '../hooks/usePerformance';
|
||||||
|
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 function PerformanceSection() {
|
||||||
|
const { period, setPeriod, funnelMax, trendMax } = usePerformance();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PerformanceTitle period={period} setPeriod={setPeriod} />
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-10">
|
||||||
|
<OverviewStatsSection />
|
||||||
|
<FunnelSection funnelMax={funnelMax} />
|
||||||
|
<TrendSection trendMax={trendMax} />
|
||||||
|
<HeatmapSection />
|
||||||
|
<ChannelPerformanceSection />
|
||||||
|
<TopContentSection />
|
||||||
|
<AIRecommendationSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { usePerformanceStore } from '../store/performanceStore';
|
interface PerformanceTitleProps {
|
||||||
|
period: '7d' | '30d' | '90d';
|
||||||
|
setPeriod: (p: '7d' | '30d' | '90d') => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function PerformanceTitle() {
|
export function PerformanceTitle({ period, setPeriod }: PerformanceTitleProps) {
|
||||||
const { period, setPeriod } = usePerformanceStore();
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#0A1128] py-14 px-6">
|
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||||||
|
<div className="max-w-6xl mx-auto relative">
|
||||||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
|
<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>
|
<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>
|
<p className="text-purple-200/70 max-w-xl mb-8">모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -17,51 +17,49 @@ const typeColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
|
||||||
export function TopContentSection() {
|
export function TopContentSection() {
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="mb-10">
|
||||||
<div className="max-w-6xl mx-auto">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
||||||
<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="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">
|
||||||
<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>채널</span>
|
||||||
<span>채널</span>
|
<span className="text-right">조회수</span>
|
||||||
<span className="text-right">조회수</span>
|
<span className="text-right">좋아요</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">CTR</span>
|
<span className="text-right">게시일</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>
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,59 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
|
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
|
||||||
import { usePerformanceStore } from '../store/performanceStore';
|
|
||||||
|
|
||||||
export function TrendSection() {
|
export function TrendSection({ trendMax }: { trendMax: number }) {
|
||||||
const { trendMax } = usePerformanceStore();
|
|
||||||
return (
|
return (
|
||||||
<section className="py-10 px-6">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
||||||
<div>
|
<p className="text-xs text-slate-500 mt-1">채널별 조회수 추이 비교 (단위: K)</p>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-1">채널별 조회수 추이 비교 (단위: K)</p>
|
<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 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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<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 rounded-xl overflow-hidden" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
|
||||||
|
{TREND_CHANNELS.map(ch => {
|
||||||
|
const val = week[ch.key];
|
||||||
|
const segH = (val / total) * 100;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={ch.key}
|
||||||
|
className="w-full relative group"
|
||||||
|
style={{ height: `${segH}%`, backgroundColor: ch.color }}
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: wi * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { HeatmapSection } from './HeatmapSection';
|
||||||
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
|
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
|
||||||
import { TopContentSection } from './TopContentSection';
|
import { TopContentSection } from './TopContentSection';
|
||||||
import { AIRecommendationSection } from './AIRecommendationSection';
|
import { AIRecommendationSection } from './AIRecommendationSection';
|
||||||
|
import { PerformanceSection } from './PerformanceSection';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PerformanceTitle,
|
PerformanceTitle,
|
||||||
|
|
@ -16,4 +17,5 @@ export {
|
||||||
ChannelPerformanceSection,
|
ChannelPerformanceSection,
|
||||||
TopContentSection,
|
TopContentSection,
|
||||||
AIRecommendationSection,
|
AIRecommendationSection,
|
||||||
|
PerformanceSection,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { ChannelScore } from "@/features/report/types/channelScore";
|
|
||||||
|
|
||||||
export const MOCK_CHANNEL_SCORES: ChannelScore[] = [
|
|
||||||
{ channel: "YouTube", icon: "youtube", score: 65, maxScore: 100, status: "warning", headline: "103K 구독자, 조회수 하락세" },
|
|
||||||
{ channel: "Instagram KR", icon: "instagram", score: 35, maxScore: 100, status: "critical", headline: "14K 팔로워, Reels 0개" },
|
|
||||||
{ channel: "Instagram EN", icon: "instagram", score: 55, maxScore: 100, status: "warning", headline: "68.8K 팔로워, 활발한 편" },
|
|
||||||
{ channel: "Facebook", icon: "facebook", score: 40, maxScore: 100, status: "critical", headline: "브랜드 불일치, 계정 분산" },
|
|
||||||
{ channel: "강남언니", icon: "star", score: 95, maxScore: 100, status: "excellent", headline: "4.8점, 18,840 리뷰" },
|
|
||||||
{ channel: "Website", icon: "globe", score: 50, maxScore: 100, status: "warning", headline: "SNS 연결 없음, 트래킹만 존재" },
|
|
||||||
];
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot";
|
|
||||||
|
|
||||||
export const MOCK_CLINIC_SNAPSHOT: ClinicSnapshot = {
|
|
||||||
name: "뷰성형외과의원",
|
|
||||||
nameEn: "VIEW Plastic Surgery",
|
|
||||||
established: "2005",
|
|
||||||
yearsInBusiness: 21,
|
|
||||||
staffCount: 28,
|
|
||||||
leadDoctor: {
|
|
||||||
name: "최순우",
|
|
||||||
credentials: "서울대 출신, 의학박사",
|
|
||||||
rating: 4.7,
|
|
||||||
reviewCount: 1809,
|
|
||||||
},
|
|
||||||
overallRating: 4.8,
|
|
||||||
totalReviews: 18840,
|
|
||||||
priceRange: { min: "97,900", max: "13,200,000+", currency: "₩" },
|
|
||||||
certifications: [
|
|
||||||
"수술실 CCTV",
|
|
||||||
"전담 마취과 전문의",
|
|
||||||
"응급대응 시스템",
|
|
||||||
"여의사 상담",
|
|
||||||
"보건복지부장관 표창",
|
|
||||||
"안면윤곽 수상",
|
|
||||||
"모티바 사용량 1위",
|
|
||||||
"19층 안전스마트 빌딩",
|
|
||||||
"렛미인 출연",
|
|
||||||
"All-In-One 시스템",
|
|
||||||
],
|
|
||||||
mediaAppearances: ["렛미인 TV 프로그램", "보건복지부장관 표창", "안면윤곽 수상"],
|
|
||||||
medicalTourism: ["VisitKorea 등재", "강남 메디컬투어센터 협력기관", "외국인 전용 서비스"],
|
|
||||||
location: "서울시 강남구 봉은사로 107 (논현동)",
|
|
||||||
nearestStation: "9호선 신논현역 3번 출구 50m",
|
|
||||||
phone: "02-539-1177",
|
|
||||||
domain: "viewclinic.com",
|
|
||||||
};
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import type { FacebookAudit } from "@/features/report/types/facebookAudit";
|
|
||||||
|
|
||||||
/** DEMO `mockReport.facebookAudit` 와 동일 시나리오 */
|
|
||||||
export const MOCK_FACEBOOK_AUDIT: FacebookAudit = {
|
|
||||||
pages: [
|
|
||||||
{
|
|
||||||
url: "facebook.com/viewps1",
|
|
||||||
pageName: "뷰성형외과",
|
|
||||||
language: "KR",
|
|
||||||
label: "국내 (한국어)",
|
|
||||||
followers: 253,
|
|
||||||
following: 0,
|
|
||||||
category: "성형외과 의사",
|
|
||||||
bio: "예쁨이 일상이 되는 순간! #뷰성형외과",
|
|
||||||
logo: "일치 (공식 로고)",
|
|
||||||
logoDescription:
|
|
||||||
"보라색+골드 깃털 공식 로고 사용 — 웹사이트와 동일한 공식 브랜드 자산. 원형 테두리 안에 깃털 심볼 + VIEW / Plastic Surgery 텍스트가 정확히 배치됨.",
|
|
||||||
link: "viewclinic.com",
|
|
||||||
linkedDomain: "viewclinic.com",
|
|
||||||
reviews: 0,
|
|
||||||
recentPostAge: "1일 전",
|
|
||||||
hasWhatsApp: false,
|
|
||||||
postFrequency: "주 1~2회 (카드뉴스 크로스포스팅)",
|
|
||||||
topContentType: "Instagram 카드뉴스 그대로 복사 게시",
|
|
||||||
engagement: "게시물당 좋아요 0~3개, 댓글 거의 없음",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: "facebook.com/viewclinic",
|
|
||||||
pageName: "View Plastic Surgery",
|
|
||||||
language: "EN",
|
|
||||||
label: "국제 (영어)",
|
|
||||||
followers: 88000,
|
|
||||||
following: 11,
|
|
||||||
category: "건강/뷰티",
|
|
||||||
bio: "Official Account by VIEW Partners",
|
|
||||||
logo: "불일치 (비공식 변형)",
|
|
||||||
logoDescription:
|
|
||||||
"VIEW 텍스트 전용 골드 로고 — 공식 깃털 심볼이 빠진 비공식 변형 버전. YouTube, Instagram EN과 동일하지만, 공식 브랜드 가이드(보라색+골드 깃털)와 불일치.",
|
|
||||||
link: "viewplasticsurgery.com",
|
|
||||||
linkedDomain: "viewplasticsurgery.com (메인 도메인 viewclinic.com과 다름)",
|
|
||||||
reviews: 3,
|
|
||||||
recentPostAge: "14분 전",
|
|
||||||
hasWhatsApp: true,
|
|
||||||
postFrequency: "일 1~2회 (Before/After, 환자 스토리)",
|
|
||||||
topContentType: "Before/After 사진 + 환자 여정 Reels",
|
|
||||||
engagement: "게시물당 좋아요 50~300개, 댓글 10~50개",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
diagnosis: [
|
|
||||||
{
|
|
||||||
category: "채널 간 로고 파편화",
|
|
||||||
detail:
|
|
||||||
"Facebook KR만 공식 깃털 로고를 사용하고, EN 페이지는 비공식 VIEW 골드 텍스트 로고를 사용. YouTube, Instagram도 각각 다른 변형 로고 사용 중.",
|
|
||||||
severity: "critical",
|
|
||||||
evidenceIds: ["fb-en-page", "ig-kr-profile", "ig-en-profile"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "KR 페이지 사실상 방치",
|
|
||||||
detail: "팔로워 253명, 리뷰 0개, 게시물 참여율 0% — 운영 비용 대비 효과 없음",
|
|
||||||
severity: "critical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "도메인 불일치",
|
|
||||||
detail:
|
|
||||||
"KR 페이지 → viewclinic.com, EN 페이지 → viewplasticsurgery.com — 서로 다른 도메인으로 연결, SEO 및 트래픽 분산",
|
|
||||||
severity: "warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "KR/EN 팔로워 348:1 격차",
|
|
||||||
detail: "EN 88K vs KR 253 — 국내 환자 유입 채널로서 Facebook KR은 완전히 실패",
|
|
||||||
severity: "critical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "KR 콘텐츠 전략 없음",
|
|
||||||
detail:
|
|
||||||
"Instagram 카드뉴스를 그대로 복사 게시 — Facebook 네이티브 콘텐츠(동영상, 이벤트, 그룹) 활용 0%",
|
|
||||||
severity: "warning",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "Facebook Pixel ↔ 페이지 비연동",
|
|
||||||
detail:
|
|
||||||
"웹사이트에 Facebook Pixel(ID: 299151214739571)이 설치되어 있으나, KR 페이지와의 광고 리타겟 연동 미확인",
|
|
||||||
severity: "warning",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
brandInconsistencies: [
|
|
||||||
{
|
|
||||||
field: "로고",
|
|
||||||
values: [
|
|
||||||
{ channel: "YouTube", value: "VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)", isCorrect: false },
|
|
||||||
{ channel: "Instagram KR", value: "모델 프로필 사진 (로고 아님)", isCorrect: false },
|
|
||||||
{ channel: "Instagram EN", value: "VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)", isCorrect: false },
|
|
||||||
{ channel: "Facebook KR", value: "보라색+골드 깃털 로고 (공식)", isCorrect: true },
|
|
||||||
{ channel: "Facebook EN", value: "VIEW 텍스트 전용 골드 로고 (깃털 심볼 없음)", isCorrect: false },
|
|
||||||
{ channel: "Website", value: "보라색+골드 깃털 로고 (공식)", isCorrect: true },
|
|
||||||
],
|
|
||||||
impact:
|
|
||||||
"공식 깃털 로고를 사용하는 채널은 Facebook KR과 웹사이트 2곳뿐. YouTube, Instagram, Facebook EN은 비공식 변형 로고 사용",
|
|
||||||
recommendation: "전 채널에 보라색+골드 깃털 공식 로고 통일 (원형: 프로필, 가로형: 배너)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "연결 도메인",
|
|
||||||
values: [
|
|
||||||
{ channel: "YouTube", value: "viewclinic.com", isCorrect: true },
|
|
||||||
{ channel: "Instagram KR", value: "litt.ly/viewplasticsurgery", isCorrect: true },
|
|
||||||
{ channel: "Instagram EN", value: "litt.ly/viewplasticsurgeryenglish", isCorrect: true },
|
|
||||||
{ channel: "Facebook KR", value: "viewclinic.com", isCorrect: true },
|
|
||||||
{ channel: "Facebook EN", value: "viewplasticsurgery.com", isCorrect: false },
|
|
||||||
],
|
|
||||||
impact:
|
|
||||||
"EN 페이지가 별도 도메인(viewplasticsurgery.com)으로 연결 → 도메인 권위(Domain Authority) 분산, SEO 불이익",
|
|
||||||
recommendation: "viewclinic.com/en 하위 경로로 국제 페이지 통합, 기존 도메인은 301 리다이렉트",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: "바이오/소개 메시지",
|
|
||||||
values: [
|
|
||||||
{ channel: "YouTube", value: "💜뷰성형외과💜 VIEW가 예술이다!", isCorrect: false },
|
|
||||||
{ channel: "Instagram KR", value: "뷰 성형외과 | 가슴성형·안면윤곽·눈성형·코성형·리프팅", isCorrect: false },
|
|
||||||
{ channel: "Facebook KR", value: "예쁨이 일상이 되는 순간! #뷰성형외과", isCorrect: false },
|
|
||||||
{ channel: "Facebook EN", value: "Official Account by VIEW Partners", isCorrect: false },
|
|
||||||
],
|
|
||||||
impact:
|
|
||||||
"4개 채널, 4개의 서로 다른 소개 메시지 → 통일된 브랜드 포지셔닝 부재, 핵심 USP(안전/21년 무사고) 미전달",
|
|
||||||
recommendation:
|
|
||||||
'핵심 USP 포함 통일 바이오: "안전이 예술이 되는 곳 — 21년 무사고 VIEW 성형외과"',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
consolidationRecommendation:
|
|
||||||
"Facebook KR 페이지(253명)는 폐쇄 또는 EN 페이지(88K)로 통합을 권장합니다. KR 페이지는 투자 대비 효과가 사실상 제로이며, 브랜드 혼란만 가중시키고 있습니다. Facebook은 한국 시장에서 오가닉 도달 목적이 아닌, Facebook Pixel 기반 리타겟 광고 전용 채널로 운영하는 것이 효율적입니다.",
|
|
||||||
};
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import type { InstagramAudit } from "@/features/report/types/instagramAudit";
|
|
||||||
|
|
||||||
export const MOCK_INSTAGRAM_AUDIT: InstagramAudit = {
|
|
||||||
accounts: [
|
|
||||||
{
|
|
||||||
handle: "@viewplastic",
|
|
||||||
language: "KR",
|
|
||||||
label: "국내 (한국어)",
|
|
||||||
posts: 1409,
|
|
||||||
followers: 14000,
|
|
||||||
following: 4760,
|
|
||||||
category: "Health/beauty",
|
|
||||||
profileLink: "litt.ly/viewplasticsurgery",
|
|
||||||
highlights: ["수술정보", "ABOUT VIEW", "모델 모집", "VIEW EVENT", "진료안내"],
|
|
||||||
reelsCount: 0,
|
|
||||||
contentFormat: "카드뉴스 (정보형 이미지) 100%",
|
|
||||||
profilePhoto: "모델 사진 (브랜드 로고 아님)",
|
|
||||||
bio: "뷰 성형외과 | 가슴성형 · 안면윤곽 · 눈성형 · 코성형 · 리프팅\n💕신논현역 3번 출구 | 카톡 '뷰성형외과의원' | 02-539-1177",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
handle: "@view_plastic_surgery",
|
|
||||||
language: "EN",
|
|
||||||
label: "국제 (영어)",
|
|
||||||
posts: 2524,
|
|
||||||
followers: 68800,
|
|
||||||
following: 2834,
|
|
||||||
category: "Health/beauty",
|
|
||||||
profileLink: "litt.ly/viewplasticsurgeryenglish",
|
|
||||||
highlights: ["Mathilde", "Thet San", "Katerina", "Yuri", "Liposuction", "Why VIEW?", "Face Contour"],
|
|
||||||
reelsCount: 50,
|
|
||||||
contentFormat: "Before/After + 환자 스토리 + Reels",
|
|
||||||
profilePhoto: "VIEW 골드 로고",
|
|
||||||
bio: "VIEW Plastic Surgery Official by VIEW Partners\n⚕ Most Renowned Hospital in Korea\n107 Bongeunsa-ro Gangnam-gu, Seoul, Korea",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
diagnosis: [
|
|
||||||
{ category: "계정 분리 → 팔로워 분산", detail: "KR 14K + EN 68.8K = 합산 82.8K이지만 각각 약함", severity: "warning" },
|
|
||||||
{ category: "KR 계정 Reels 전무", detail: "인스타 알고리즘 핵심인 Reels 콘텐츠 0개", severity: "critical", evidenceIds: ["ig-kr-profile"] },
|
|
||||||
{ category: "브랜드 비주얼 불일치", detail: "KR=모델 프사, EN=VIEW 골드 로고", severity: "warning", evidenceIds: ["ig-kr-profile", "ig-en-profile"] },
|
|
||||||
{ category: "KR 팔로잉 과다", detail: "4,760 팔로잉 — 팔로우백 전략 의심", severity: "warning" },
|
|
||||||
{ category: "크로스포스팅 없음", detail: "YouTube Shorts → Instagram Reels 연동 없음", severity: "critical" },
|
|
||||||
{ category: "유튜브 ↔ 인스타 유입 단절", detail: "103K 구독자 → 14K 팔로워 전환 실패", severity: "critical" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import type { KpiMetric } from "@/features/report/types/kpiDashboard";
|
|
||||||
|
|
||||||
/** DEMO `mockReport.kpiDashboard` */
|
|
||||||
export const MOCK_KPI_METRICS: KpiMetric[] = [
|
|
||||||
{ metric: "YouTube 구독자", current: "103K", target3Month: "115K", target12Month: "200K" },
|
|
||||||
{ metric: "YouTube 월 조회수", current: "~270K", target3Month: "500K", target12Month: "1.5M" },
|
|
||||||
{ metric: "YouTube Shorts 평균 조회수", current: "500~1,000", target3Month: "5,000", target12Month: "20,000" },
|
|
||||||
{ metric: "Instagram KR 팔로워", current: "14K", target3Month: "20K", target12Month: "50K" },
|
|
||||||
{ metric: "Instagram KR Reels 평균 조회수", current: "0 (없음)", target3Month: "3,000", target12Month: "10,000" },
|
|
||||||
{ metric: "Instagram EN 팔로워", current: "68.8K", target3Month: "75K", target12Month: "100K" },
|
|
||||||
{ metric: "네이버 블로그 방문자", current: "0 (없음)", target3Month: "5,000/월", target12Month: "30,000/월" },
|
|
||||||
{ metric: "웹사이트 → SNS 유입", current: "0%", target3Month: "5%", target12Month: "15%" },
|
|
||||||
{ metric: "콘텐츠 → 상담 전환", current: "측정 불가", target3Month: "UTM 추적 시작", target12Month: "월 50건" },
|
|
||||||
];
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import type { OtherChannelsReport } from "@/types/otherChannels";
|
|
||||||
|
|
||||||
/** DEMO `mockReport` 기타 채널 + 웹사이트 진단 */
|
|
||||||
export const MOCK_OTHER_CHANNELS_REPORT: OtherChannelsReport = {
|
|
||||||
channels: [
|
|
||||||
{ name: "카카오톡", status: "active", details: "상담 전용 채널 운영", url: "pf.kakao.com/_xbtVxjl" },
|
|
||||||
{ name: "네이버 블로그", status: "unknown", details: "Naver API 연동 필요" },
|
|
||||||
{ name: "네이버 플레이스", status: "unknown", details: "Naver API 연동 필요" },
|
|
||||||
{ name: "TikTok", status: "not_found", details: "계정 없음 또는 비활성" },
|
|
||||||
{
|
|
||||||
name: "강남언니",
|
|
||||||
status: "active",
|
|
||||||
details: "4.8점, 18,840 리뷰, 28 의료진",
|
|
||||||
url: "gangnamunni.com/hospitals/189",
|
|
||||||
},
|
|
||||||
{ name: "모두닥", status: "active", details: "기본 정보 등재" },
|
|
||||||
{ name: "Goodoc", status: "active", details: "기본 정보 등재" },
|
|
||||||
{ name: "닥터나우", status: "active", details: "기본 정보 등재" },
|
|
||||||
],
|
|
||||||
website: {
|
|
||||||
primaryDomain: "viewclinic.com",
|
|
||||||
additionalDomains: [
|
|
||||||
{ domain: "viewplasticsurgery.com", purpose: "영문 국제 사이트" },
|
|
||||||
{ domain: "viewclinic-chat.com", purpose: "채팅 상담 전용" },
|
|
||||||
{ domain: "viewclinic.modoo.at", purpose: "구 모두홈페이지" },
|
|
||||||
],
|
|
||||||
snsLinksOnSite: false,
|
|
||||||
trackingPixels: [
|
|
||||||
{ name: "Facebook Pixel", installed: true, details: "ID: 299151214739571" },
|
|
||||||
{ name: "Kakao Pixel", installed: true },
|
|
||||||
{ name: "Google Tag Manager", installed: true, details: "GTM-52RT6DMK" },
|
|
||||||
],
|
|
||||||
mainCTA: "전화 + 카카오톡 상담",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
|
||||||
|
|
||||||
/** DEMO `mockReport.problemDiagnosis` */
|
|
||||||
export const MOCK_PROBLEM_DIAGNOSIS: DiagnosisItem[] = [
|
|
||||||
{
|
|
||||||
category: "브랜드 아이덴티티 파편화",
|
|
||||||
detail:
|
|
||||||
"공식 깃털 로고(보라색+골드)는 Facebook KR과 웹사이트에만 사용. YouTube/Instagram EN/Facebook EN은 비공식 골드 텍스트 로고, Instagram KR은 모델 사진 사용 — 6개 채널에 4종의 서로 다른 시각적 아이덴티티",
|
|
||||||
severity: "critical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "콘텐츠 전략 부재",
|
|
||||||
detail:
|
|
||||||
"콘텐츠 캘린더 없음, 톤앤매너 가이드 없음, KR↔EN 시너지 없음, YouTube→Instagram 크로스포스팅 없음",
|
|
||||||
severity: "critical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
category: "플랫폼 간 유입 단절",
|
|
||||||
detail:
|
|
||||||
"YouTube 103K → Instagram 14K 전환 실패, 웹사이트에 SNS 링크 0개, 강남언니 18.8K 리뷰→영상 전환 없음",
|
|
||||||
severity: "critical",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import type { ReportOverviewData } from "@/features/report/types/reportOverview";
|
|
||||||
|
|
||||||
/** API 연동 전 — DEMO mockReport 헤더 필드와 동일 계열 */
|
|
||||||
export const MOCK_REPORT_OVERVIEW: ReportOverviewData = {
|
|
||||||
clinicName: "뷰성형외과의원",
|
|
||||||
clinicNameEn: "VIEW Plastic Surgery",
|
|
||||||
overallScore: 62,
|
|
||||||
date: "2026-03-22",
|
|
||||||
targetUrl: "https://www.viewclinic.com",
|
|
||||||
location: "서울시 강남구 봉은사로 107 (논현동)",
|
|
||||||
};
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import type { RoadmapMonth } from "@/features/report/types/roadmap";
|
|
||||||
|
|
||||||
/** DEMO `mockReport.roadmap` */
|
|
||||||
export const MOCK_ROADMAP: RoadmapMonth[] = [
|
|
||||||
{
|
|
||||||
month: 1,
|
|
||||||
title: "Foundation",
|
|
||||||
subtitle: "기반 구축",
|
|
||||||
tasks: [
|
|
||||||
{ task: "브랜드 아이덴티티 가이드 확정 (로고, 컬러, 폰트, 톤앤매너)", completed: false },
|
|
||||||
{ task: "전 채널 프로필 사진/배너 통일 교체", completed: false },
|
|
||||||
{ task: "Facebook KR 페이지 정리 (통합 또는 폐쇄)", completed: false },
|
|
||||||
{ task: "Instagram KR 팔로잉 정리 (4,760 → 300)", completed: false },
|
|
||||||
{ task: "웹사이트에 YouTube/Instagram 링크 추가", completed: false },
|
|
||||||
{ task: "기존 YouTube 영상 100개 → AI 숏폼 추출 시작", completed: false },
|
|
||||||
{ task: "콘텐츠 캘린더 v1 수립", completed: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
month: 2,
|
|
||||||
title: "Content Engine",
|
|
||||||
subtitle: "콘텐츠 엔진 가동",
|
|
||||||
tasks: [
|
|
||||||
{ task: "YouTube Shorts 주 3~5회 업로드 시작", completed: false },
|
|
||||||
{ task: "Instagram Reels 주 5회 업로드 시작", completed: false },
|
|
||||||
{ task: "원장 촬영 세션 월 2회 스케줄 확정", completed: false },
|
|
||||||
{ task: '"원장이 설명하는" 시리즈 4편 제작/업로드', completed: false },
|
|
||||||
{ task: "네이버 블로그 개설 및 시술 가이드 10편 게시", completed: false },
|
|
||||||
{ task: "TikTok 계정 개설 및 Shorts 동시 배포", completed: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
month: 3,
|
|
||||||
title: "Optimization",
|
|
||||||
subtitle: "최적화 & 광고",
|
|
||||||
tasks: [
|
|
||||||
{ task: "콘텐츠 성과 분석 리포트 v1", completed: false },
|
|
||||||
{ task: "고성과 콘텐츠 기반 Instagram/Facebook 광고 세팅", completed: false },
|
|
||||||
{ task: "YouTube 썸네일 A/B 테스트", completed: false },
|
|
||||||
{ task: "콘텐츠 캘린더 v2 (성과 데이터 반영)", completed: false },
|
|
||||||
{ task: "네이버 플레이스 최적화", completed: false },
|
|
||||||
{ task: "KPI 리뷰: 구독자/팔로워 성장률, 상담 전환 추적", completed: false },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import type { TransformationProposal } from "@/features/report/types/transformationProposal";
|
|
||||||
|
|
||||||
/** DEMO `mockReport.transformation` */
|
|
||||||
export const MOCK_TRANSFORMATION: TransformationProposal = {
|
|
||||||
brandIdentity: [
|
|
||||||
{ area: "로고", asIs: "채널마다 다른 로고 4종", toBe: "VIEW 골드 로고 1종 통일" },
|
|
||||||
{ area: "컬러 팔레트", asIs: "없음 (혼재)", toBe: "Primary: Gold (#C4A462) + Dark (#1A1A1A)" },
|
|
||||||
{ area: "프로필 사진", asIs: "KR=모델, EN=로고, FB=깃털", toBe: "전 채널 VIEW 골드 로고 통일" },
|
|
||||||
{ area: "바이오 메시지", asIs: "채널마다 다른 메시지", toBe: '"안전이 예술이 되는 곳 — 21년 무사고 VIEW"' },
|
|
||||||
{ area: "해시태그", asIs: "비체계적", toBe: "#뷰성형외과 #VIEW성형 #강남성형외과 #21년무사고" },
|
|
||||||
],
|
|
||||||
contentStrategy: [
|
|
||||||
{ area: "콘텐츠 캘린더", asIs: "없음", toBe: "월간 콘텐츠 캘린더 (4주 사이클)" },
|
|
||||||
{
|
|
||||||
area: "업로드 빈도",
|
|
||||||
asIs: "YouTube 주1회, Instagram 비정기",
|
|
||||||
toBe: "YouTube 주3회 + Instagram 일1회 + Shorts/Reels 주5회",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "콘텐츠 포맷",
|
|
||||||
asIs: "KR Instagram = 카드뉴스만",
|
|
||||||
toBe: "카드뉴스 30% + Reels 40% + 카루셀 20% + Stories 10%",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
area: "콘텐츠 앵글",
|
|
||||||
asIs: "시술 정보 중심 (병원 관점)",
|
|
||||||
toBe: "환자 의사결정 보조 중심 (환자 관점)",
|
|
||||||
},
|
|
||||||
{ area: "톤앤매너", asIs: "없음", toBe: '"차분한 전문가" — 과장 없이, 설명으로 설득' },
|
|
||||||
],
|
|
||||||
platformStrategies: [
|
|
||||||
{
|
|
||||||
platform: "YouTube",
|
|
||||||
icon: "youtube",
|
|
||||||
currentMetric: "103K subscribers",
|
|
||||||
targetMetric: "200K / 12개월",
|
|
||||||
strategies: [
|
|
||||||
{ strategy: "업로드 빈도 3배 증가", detail: "주 3회 (롱폼 1 + Shorts 2)" },
|
|
||||||
{ strategy: "기존 영상 재활용", detail: "1,064개 기존 영상에서 AI 숏폼 100개 추출" },
|
|
||||||
{ strategy: "썸네일 시스템화", detail: "VIEW 골드 워터마크 + 일관된 폰트/컬러" },
|
|
||||||
{ strategy: "커뮤니티 탭 활용", detail: "주 2회 투표/질문 — 구독자 참여 활성화" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
platform: "Instagram KR",
|
|
||||||
icon: "instagram",
|
|
||||||
currentMetric: "14K followers",
|
|
||||||
targetMetric: "50K / 12개월",
|
|
||||||
strategies: [
|
|
||||||
{ strategy: "Reels 즉시 시작", detail: "YouTube Shorts 동시 게시 → 최소 주 5개" },
|
|
||||||
{ strategy: "프로필 사진 교체", detail: "모델 사진 → VIEW 골드 로고" },
|
|
||||||
{ strategy: "팔로잉 정리", detail: "4,760 → 300 이하로 정리" },
|
|
||||||
{ strategy: "Stories 활성화", detail: "일 2~3개 (상담 비하인드, 병원 일상)" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
platform: "Facebook",
|
|
||||||
icon: "facebook",
|
|
||||||
currentMetric: "KR 253 + EN 88K",
|
|
||||||
targetMetric: "통합 관리",
|
|
||||||
strategies: [
|
|
||||||
{ strategy: "계정 통합", detail: "KR 253명 페이지 → EN 88K 페이지로 통합 또는 폐쇄" },
|
|
||||||
{ strategy: "로고 통일", detail: "보라색 깃털 → VIEW 골드 로고" },
|
|
||||||
{ strategy: "역할 정의", detail: "FB = 광고 랜딩 + 리타겟 전용" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
websiteImprovements: [
|
|
||||||
{
|
|
||||||
area: "SNS 링크",
|
|
||||||
asIs: "홈페이지에 0개",
|
|
||||||
toBe: "Header/Footer에 YouTube + Instagram + KakaoTalk 링크",
|
|
||||||
},
|
|
||||||
{ area: "YouTube 임베드", asIs: "없음", toBe: "시술 페이지별 관련 YouTube 영상 임베드" },
|
|
||||||
{ area: "콘텐츠 허브", asIs: "없음", toBe: "SEO 콘텐츠 허브 구축 (시술별 가이드)" },
|
|
||||||
{ area: "도메인 통합", asIs: "4개 도메인 분산", toBe: "viewclinic.com 단일 도메인 + /en 국제 페이지" },
|
|
||||||
],
|
|
||||||
newChannelProposals: [
|
|
||||||
{ channel: "TikTok", priority: "P1", rationale: "20~30대 첫 수술 고민층 도달, YouTube Shorts 동시 배포" },
|
|
||||||
{ channel: "네이버 블로그", priority: "P0", rationale: "한국 검색 1위 플랫폼 — SEO 핵심" },
|
|
||||||
{ channel: "네이버 플레이스", priority: "P0", rationale: "지역 검색 노출 필수" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import type { YouTubeAudit } from "@/features/report/types/youtubeAudit";
|
|
||||||
|
|
||||||
export const MOCK_YOUTUBE_AUDIT: YouTubeAudit = {
|
|
||||||
channelName: "뷰성형외과 VIEW Plastic Surgery",
|
|
||||||
handle: "@ViewclinicKR",
|
|
||||||
subscribers: 103000,
|
|
||||||
totalVideos: 1064,
|
|
||||||
totalViews: 9952722,
|
|
||||||
weeklyViewGrowth: { absolute: 67097, percentage: 4.09 },
|
|
||||||
estimatedMonthlyRevenue: { min: 499, max: 1000 },
|
|
||||||
avgVideoLength: "4.4분",
|
|
||||||
uploadFrequency: "~주 1회",
|
|
||||||
channelCreatedDate: "2015-06-29",
|
|
||||||
subscriberRank: "#570K",
|
|
||||||
channelDescription:
|
|
||||||
"💜뷰성형외과💜\nVIEW가 예술이다! ✨\n19층 규모의 안전스마트 빌딩\n환자의 관점에서 생각하고\n환자의 입장에서 아름다움의 가치를 찾습니다.",
|
|
||||||
linkedUrls: [
|
|
||||||
{ label: "뷰성형외과 홈페이지", url: "viewclinic.com" },
|
|
||||||
{ label: "Instagram", url: "instagram.com/viewplastic" },
|
|
||||||
{ label: "이벤트 보기", url: "viewclinic.com/board/events" },
|
|
||||||
{ label: "상담 예약", url: "viewclinic.com/counsel/reservation" },
|
|
||||||
{ label: "카톡 상담", url: "pf.kakao.com/_xbtVxjl" },
|
|
||||||
],
|
|
||||||
playlists: [
|
|
||||||
"VIEW 💜 무엇이든 물어보세요",
|
|
||||||
"VIEW 💜 재수술",
|
|
||||||
"VIEW 💜 가슴",
|
|
||||||
"VIEW 💜 눈+코",
|
|
||||||
"VIEW 💜 윤곽+양악",
|
|
||||||
"VIEW 💜 지방성형",
|
|
||||||
"VIEW 💜 피부+안티에이징",
|
|
||||||
"VIEW랜딩 💜",
|
|
||||||
"VIEW 💜 방송영상",
|
|
||||||
],
|
|
||||||
topVideos: [
|
|
||||||
{ title: "한번에 성공하는 성형", views: 574000, uploadedAgo: "4년 전", type: "Short" },
|
|
||||||
{ title: "코성형+지방이식 전후", views: 525000, uploadedAgo: "4년 전", type: "Short" },
|
|
||||||
{ title: "쌍수+뒤밑트임 전후", views: 392000, uploadedAgo: "3년 전", type: "Short" },
|
|
||||||
{ title: "V라인턱 변신과정 전격공개", views: 194000, uploadedAgo: "4년 전", type: "Short" },
|
|
||||||
{ title: "K-미녀 클라스", views: 161000, uploadedAgo: "4년 전", type: "Short" },
|
|
||||||
{ title: "앞트임하면 대박나는 사람", views: 154000, uploadedAgo: "2년 전", type: "Short" },
|
|
||||||
{
|
|
||||||
title: "코성형! 내 얼굴에 가장 예쁜 코 찾아드립니다",
|
|
||||||
views: 124000,
|
|
||||||
uploadedAgo: "3년 전",
|
|
||||||
type: "Long",
|
|
||||||
duration: "7:59",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "아나운서 박은영, 가슴 할 결심을 하다",
|
|
||||||
views: 127000,
|
|
||||||
uploadedAgo: "9개월 전",
|
|
||||||
type: "Long",
|
|
||||||
duration: "43:39",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
diagnosis: [
|
|
||||||
{ category: "구독자 대비 조회수 비율", detail: "영상당 평균 ~9,300회 (103K 구독자 대비 9% 도달률)", severity: "critical", evidenceIds: ["yt-channel"] },
|
|
||||||
{ category: "최근 롱폼 조회수", detail: "대부분 1,000~4,000회 수준", severity: "critical" },
|
|
||||||
{ category: "Shorts 조회수", detail: "최근 업로드 500~1,000회 (과거 대비 급감)", severity: "warning" },
|
|
||||||
{ category: "업로드 빈도", detail: "주 1회 — 알고리즘 노출 최소 기준 미달", severity: "warning" },
|
|
||||||
{ category: "콘텐츠 톤앤매너", detail: "일관성 없음 — 교육/Q&A/전후/브랜딩 혼재", severity: "critical" },
|
|
||||||
{ category: "썸네일 디자인", detail: "통일된 브랜드 시스템 없음", severity: "warning" },
|
|
||||||
{ category: "최고 성과 Shorts", detail: "4년 전 콘텐츠 — 최근 재현 실패", severity: "critical" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
/**
|
|
||||||
* SubNav·IntersectionObserver와 각 섹션 `id`가 일치해야 합니다.
|
|
||||||
*/
|
|
||||||
export const REPORT_SECTIONS = [
|
|
||||||
{ id: "header", label: "개요" },
|
|
||||||
{ id: "clinic-snapshot", label: "의원 현황" },
|
|
||||||
{ id: "channel-overview", label: "채널 종합" },
|
|
||||||
{ id: "youtube-audit", label: "YouTube" },
|
|
||||||
{ id: "instagram-audit", label: "Instagram" },
|
|
||||||
{ id: "facebook-audit", label: "Facebook" },
|
|
||||||
{ id: "other-channels", label: "기타 채널" },
|
|
||||||
{ id: "problem-diagnosis", label: "문제 진단" },
|
|
||||||
{ id: "transformation", label: "변환 전략" },
|
|
||||||
{ id: "roadmap", label: "로드맵" },
|
|
||||||
{ id: "kpi-dashboard", label: "KPI" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ReportSectionId = (typeof REPORT_SECTIONS)[number]["id"];
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useMainSubNav } from "@/layouts/MainSubNavLayout";
|
|
||||||
import type { SubNavItem } from "@/layouts/SubNav";
|
|
||||||
import { REPORT_SECTIONS } from "@/features/report/constants/report_sections";
|
|
||||||
|
|
||||||
export function useReportSubNav() {
|
|
||||||
const { setSubNav } = useMainSubNav();
|
|
||||||
const [activeId, setActiveId] = useState<string>(REPORT_SECTIONS[0]?.id ?? "");
|
|
||||||
|
|
||||||
const items: SubNavItem[] = useMemo(
|
|
||||||
() =>
|
|
||||||
REPORT_SECTIONS.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
label: s.label,
|
|
||||||
targetId: s.id,
|
|
||||||
})),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const visible = entries
|
|
||||||
.filter((e) => e.isIntersecting)
|
|
||||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
||||||
if (visible.length > 0) {
|
|
||||||
setActiveId(visible[0].target.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ rootMargin: "-100px 0px -60% 0px", threshold: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
REPORT_SECTIONS.forEach(({ id: sectionId }) => {
|
|
||||||
const el = document.getElementById(sectionId);
|
|
||||||
if (el) observer.observe(el);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSubNav({
|
|
||||||
items,
|
|
||||||
activeId,
|
|
||||||
scrollActiveIntoView: true,
|
|
||||||
});
|
|
||||||
}, [activeId, items, setSubNav]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => setSubNav(null);
|
|
||||||
}, [setSubNav]);
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { Severity } from "@/types/severity";
|
|
||||||
|
|
||||||
export type ChannelScore = {
|
|
||||||
channel: string;
|
|
||||||
icon: string;
|
|
||||||
score: number;
|
|
||||||
maxScore: number;
|
|
||||||
status: Severity;
|
|
||||||
headline: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
export type ClinicSnapshot = {
|
|
||||||
name: string;
|
|
||||||
nameEn: string;
|
|
||||||
established: string;
|
|
||||||
yearsInBusiness: number;
|
|
||||||
staffCount: number;
|
|
||||||
leadDoctor: {
|
|
||||||
name: string;
|
|
||||||
credentials: string;
|
|
||||||
rating: number;
|
|
||||||
reviewCount: number;
|
|
||||||
};
|
|
||||||
overallRating: number;
|
|
||||||
totalReviews: number;
|
|
||||||
priceRange: { min: string; max: string; currency: string };
|
|
||||||
certifications: string[];
|
|
||||||
mediaAppearances: string[];
|
|
||||||
medicalTourism: string[];
|
|
||||||
location: string;
|
|
||||||
nearestStation: string;
|
|
||||||
phone: string;
|
|
||||||
domain: string;
|
|
||||||
logoImages?: {
|
|
||||||
circle?: string;
|
|
||||||
horizontal?: string;
|
|
||||||
korean?: string;
|
|
||||||
};
|
|
||||||
brandColors?: {
|
|
||||||
primary: string;
|
|
||||||
accent: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import type { Severity } from "@/types/severity";
|
|
||||||
|
|
||||||
export type DiagnosisItem = {
|
|
||||||
category: string;
|
|
||||||
detail: string;
|
|
||||||
severity: Severity;
|
|
||||||
evidenceIds?: string[];
|
|
||||||
};
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import type { BrandInconsistency } from "@/types/brandConsistency";
|
|
||||||
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
|
||||||
|
|
||||||
export type FacebookPage = {
|
|
||||||
url: string;
|
|
||||||
pageName: string;
|
|
||||||
language: "KR" | "EN";
|
|
||||||
label: string;
|
|
||||||
followers: number;
|
|
||||||
following: number;
|
|
||||||
category: string;
|
|
||||||
bio: string;
|
|
||||||
logo: string;
|
|
||||||
logoDescription: string;
|
|
||||||
link: string;
|
|
||||||
linkedDomain: string;
|
|
||||||
reviews: number;
|
|
||||||
recentPostAge: string;
|
|
||||||
hasWhatsApp: boolean;
|
|
||||||
postFrequency?: string;
|
|
||||||
topContentType?: string;
|
|
||||||
engagement?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FacebookAudit = {
|
|
||||||
pages: FacebookPage[];
|
|
||||||
diagnosis: DiagnosisItem[];
|
|
||||||
brandInconsistencies: BrandInconsistency[];
|
|
||||||
consolidationRecommendation: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
|
||||||
|
|
||||||
export type InstagramAccount = {
|
|
||||||
handle: string;
|
|
||||||
language: "KR" | "EN";
|
|
||||||
label: string;
|
|
||||||
posts: number;
|
|
||||||
followers: number;
|
|
||||||
following: number;
|
|
||||||
category: string;
|
|
||||||
profileLink: string;
|
|
||||||
highlights: string[];
|
|
||||||
reelsCount: number;
|
|
||||||
contentFormat: string;
|
|
||||||
profilePhoto: string;
|
|
||||||
bio: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InstagramAudit = {
|
|
||||||
accounts: InstagramAccount[];
|
|
||||||
diagnosis: DiagnosisItem[];
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
export type KpiMetric = {
|
|
||||||
metric: string;
|
|
||||||
current: string;
|
|
||||||
target3Month: string;
|
|
||||||
target12Month: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export type ReportOverviewData = {
|
|
||||||
clinicName: string;
|
|
||||||
clinicNameEn: string;
|
|
||||||
overallScore: number;
|
|
||||||
date: string;
|
|
||||||
targetUrl: string;
|
|
||||||
location: string;
|
|
||||||
logoImage?: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
export type RoadmapTask = {
|
|
||||||
task: string;
|
|
||||||
completed: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RoadmapMonth = {
|
|
||||||
month: number;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
tasks: RoadmapTask[];
|
|
||||||
};
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
export type AsIsToBeItem = {
|
|
||||||
area: string;
|
|
||||||
asIs: string;
|
|
||||||
toBe: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlatformStrategyItem = {
|
|
||||||
strategy: string;
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlatformStrategy = {
|
|
||||||
platform: string;
|
|
||||||
icon: string;
|
|
||||||
currentMetric: string;
|
|
||||||
targetMetric: string;
|
|
||||||
strategies: PlatformStrategyItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NewChannelProposal = {
|
|
||||||
channel: string;
|
|
||||||
priority: string;
|
|
||||||
rationale: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TransformationProposal = {
|
|
||||||
brandIdentity: AsIsToBeItem[];
|
|
||||||
contentStrategy: AsIsToBeItem[];
|
|
||||||
platformStrategies: PlatformStrategy[];
|
|
||||||
websiteImprovements: AsIsToBeItem[];
|
|
||||||
newChannelProposals: NewChannelProposal[];
|
|
||||||
};
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
|
||||||
|
|
||||||
export type TopVideo = {
|
|
||||||
title: string;
|
|
||||||
views: number;
|
|
||||||
uploadedAgo: string;
|
|
||||||
type: "Short" | "Long";
|
|
||||||
duration?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type YouTubeAudit = {
|
|
||||||
channelName: string;
|
|
||||||
handle: string;
|
|
||||||
subscribers: number;
|
|
||||||
totalVideos: number;
|
|
||||||
totalViews: number;
|
|
||||||
weeklyViewGrowth: { absolute: number; percentage: number };
|
|
||||||
estimatedMonthlyRevenue: { min: number; max: number };
|
|
||||||
avgVideoLength: string;
|
|
||||||
uploadFrequency: string;
|
|
||||||
channelCreatedDate: string;
|
|
||||||
subscriberRank: string;
|
|
||||||
channelDescription: string;
|
|
||||||
linkedUrls: { label: string; url: string }[];
|
|
||||||
playlists: string[];
|
|
||||||
topVideos: TopVideo[];
|
|
||||||
diagnosis: DiagnosisItem[];
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { PageSection } from "@/components/section/PageSection";
|
|
||||||
import { MOCK_CHANNEL_SCORES } from "@/features/report/constants/mock_channel_scores";
|
|
||||||
import type { ChannelScore } from "@/features/report/types/channelScore";
|
|
||||||
import { ChannelScoreGrid } from "@/features/report/ui/channels/ChannelScoreGrid";
|
|
||||||
|
|
||||||
type ReportChannelsSectionProps = {
|
|
||||||
channels?: ChannelScore[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportChannelsSection({ channels = MOCK_CHANNEL_SCORES }: ReportChannelsSectionProps) {
|
|
||||||
return (
|
|
||||||
<PageSection id="channel-overview" title="Channel Health Score" subtitle="채널별 건강도 종합">
|
|
||||||
<ChannelScoreGrid channels={channels} />
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { PageSection } from "@/components/section/PageSection";
|
|
||||||
import { MOCK_CLINIC_SNAPSHOT } from "@/features/report/constants/mock_clinic_snapshot";
|
|
||||||
import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot";
|
|
||||||
import { ClinicCertificationsBlock } from "@/features/report/ui/clinic/ClinicCertificationsBlock";
|
|
||||||
import { ClinicInfoStatGrid } from "@/features/report/ui/clinic/ClinicInfoStatGrid";
|
|
||||||
import { ClinicLeadDoctorPanel } from "@/features/report/ui/clinic/ClinicLeadDoctorPanel";
|
|
||||||
|
|
||||||
type ReportClinicSectionProps = {
|
|
||||||
data?: ClinicSnapshot;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportClinicSection({ data = MOCK_CLINIC_SNAPSHOT }: ReportClinicSectionProps) {
|
|
||||||
return (
|
|
||||||
<PageSection id="clinic-snapshot" title="Clinic Overview" subtitle="병원 기본 정보">
|
|
||||||
<ClinicInfoStatGrid data={data} />
|
|
||||||
<ClinicLeadDoctorPanel data={data} />
|
|
||||||
<ClinicCertificationsBlock tags={data.certifications} />
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import { PageSection } from "@/components/section/PageSection";
|
|
||||||
import { MOCK_PROBLEM_DIAGNOSIS } from "@/features/report/constants/mock_problem_diagnosis";
|
|
||||||
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
|
||||||
import { ProblemDiagnosisCard } from "@/features/report/ui/diagnosis/ProblemDiagnosisCard";
|
|
||||||
|
|
||||||
type ReportDiagnosisSectionProps = {
|
|
||||||
items?: DiagnosisItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportDiagnosisSection({ items = MOCK_PROBLEM_DIAGNOSIS }: ReportDiagnosisSectionProps) {
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<PageSection id="problem-diagnosis" title="Critical Issues" subtitle="핵심 문제 진단" dark>
|
|
||||||
<p className="body-14 text-lavender-200 break-keep">등록된 핵심 진단 항목이 없습니다.</p>
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageSection id="problem-diagnosis" title="Critical Issues" subtitle="핵심 문제 진단" dark>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<ProblemDiagnosisCard key={`${item.category}-${i}`} item={item} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import GlobeIcon from "@/assets/report/globe.svg?react";
|
|
||||||
import { BrandConsistencyMap } from "@/components/brand/BrandConsistencyMap";
|
|
||||||
import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow";
|
|
||||||
import { ConsolidationCallout } from "@/components/panel/ConsolidationCallout";
|
|
||||||
import { PageSection } from "@/components/section/PageSection";
|
|
||||||
import { MOCK_FACEBOOK_AUDIT } from "@/features/report/constants/mock_facebook_audit";
|
|
||||||
import type { FacebookAudit } from "@/features/report/types/facebookAudit";
|
|
||||||
import { FacebookPageCard } from "@/features/report/ui/facebook/FacebookPageCard";
|
|
||||||
|
|
||||||
type ReportFacebookSectionProps = {
|
|
||||||
data?: FacebookAudit;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportFacebookSection({ data = MOCK_FACEBOOK_AUDIT }: ReportFacebookSectionProps) {
|
|
||||||
return (
|
|
||||||
<PageSection id="facebook-audit" title="Facebook Analysis" subtitle="페이스북 페이지 분석">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
{data.pages.map((page, i) => (
|
|
||||||
<FacebookPageCard key={page.pageName} page={page} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.brandInconsistencies.length > 0 ? (
|
|
||||||
<BrandConsistencyMap inconsistencies={data.brandInconsistencies} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data.diagnosis.length > 0 ? (
|
|
||||||
<div className="mt-8 rounded-2xl bg-white border border-neutral-20 shadow-sm p-6 card-shadow animate-fade-in-up animation-delay-300">
|
|
||||||
<p className="body-14-medium text-neutral-80 mb-2 break-keep">진단 결과</p>
|
|
||||||
<p className="label-12 text-neutral-60 mb-4 break-keep">Facebook 채널 문제점</p>
|
|
||||||
{data.diagnosis.map((item, i) => (
|
|
||||||
<DiagnosisRow
|
|
||||||
key={`${item.category}-${i}`}
|
|
||||||
category={item.category}
|
|
||||||
detail={item.detail}
|
|
||||||
severity={item.severity}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{data.consolidationRecommendation ? (
|
|
||||||
<ConsolidationCallout
|
|
||||||
title="통합 권장 사항"
|
|
||||||
icon={<GlobeIcon width={20} height={20} className="text-white" aria-hidden />}
|
|
||||||
>
|
|
||||||
{data.consolidationRecommendation}
|
|
||||||
</ConsolidationCallout>
|
|
||||||
) : null}
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow";
|
|
||||||
import { PageSection } from "@/components/section/PageSection";
|
|
||||||
import { MOCK_INSTAGRAM_AUDIT } from "@/features/report/constants/mock_instagram_audit";
|
|
||||||
import type { InstagramAudit } from "@/features/report/types/instagramAudit";
|
|
||||||
import { InstagramAccountCard } from "@/features/report/ui/instagram/InstagramAccountCard";
|
|
||||||
|
|
||||||
type ReportInstagramSectionProps = {
|
|
||||||
data?: InstagramAudit;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ReportInstagramSection({ data = MOCK_INSTAGRAM_AUDIT }: ReportInstagramSectionProps) {
|
|
||||||
return (
|
|
||||||
<PageSection id="instagram-audit" title="Instagram Analysis" subtitle="인스타그램 채널 분석">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
||||||
{data.accounts.map((account, i) => (
|
|
||||||
<InstagramAccountCard key={account.handle} account={account} index={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.diagnosis.length > 0 ? (
|
|
||||||
<div className="rounded-2xl bg-white border border-neutral-20 shadow-sm p-6 card-shadow animate-fade-in-up animation-delay-300">
|
|
||||||
<p className="body-14-medium text-neutral-80 mb-4 break-keep">진단 결과</p>
|
|
||||||
{data.diagnosis.map((item, i) => (
|
|
||||||
<DiagnosisRow
|
|
||||||
key={`${item.category}-${i}`}
|
|
||||||
category={item.category}
|
|
||||||
detail={item.detail}
|
|
||||||
severity={item.severity}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||