|
|
@ -1,9 +1,11 @@
|
||||||
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 { ReportPage } from "@/pages/Report";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
|
|
@ -12,6 +14,9 @@ function App() {
|
||||||
<Route element={<MainLayout />}>
|
<Route element={<MainLayout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
</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 bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
@apply cursor-pointer bg-gradient-to-r from-violet-700 to-navy-950 text-white rounded-full font-medium transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Typography Scale ────────────────────────────────────────────── */
|
/* ─── Typography Scale ────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 5v14M19 12l-7 7-7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 19V5M5 12l7-7 7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 226 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="8" r="6" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M8.21 13.89 7 23l5-3 5 3-1.21-9.12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 312 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 423 B |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="5" stroke="currentColor" stroke-width="2" />
|
||||||
|
<circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="2" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 329 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="m21 21-4.3-4.3" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 269 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 189 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 511 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 225 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M22 7 13.5 15.5 8.5 10.5 2 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M16 7h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 345 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm11-4a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<rect x="2" y="6" width="14" height="12" rx="2" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M12 9v4M12 17h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 429 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M7 17L17 7M17 7H9M17 7V15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 271 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 294 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M9 12l2 2 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 312 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 238 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 3v12m0 0l4-4m-4 4L8 11M5 21h14"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 280 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 454 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path
|
||||||
|
d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4M12 17h.01"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 367 B |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
|
||||||
|
<path d="M21 15l-5-5L5 21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M9 17H7A5 5 0 0 1 7 7h2M15 7h2a5 5 0 1 1 0 10h-2M8 12h8"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 301 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 347 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M7.9 20A9 9 0 1 0 4 16.1L2 22l5.9-2z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 282 B |
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 293 B |
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Severity } from "@/types/severity";
|
||||||
|
|
||||||
|
export type SeverityBadgeProps = {
|
||||||
|
severity: Severity;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Record<Severity, { className: string; defaultLabel: string }> = {
|
||||||
|
critical: {
|
||||||
|
className:
|
||||||
|
"bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]",
|
||||||
|
defaultLabel: "심각",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
className:
|
||||||
|
"bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]",
|
||||||
|
defaultLabel: "주의",
|
||||||
|
},
|
||||||
|
good: {
|
||||||
|
className:
|
||||||
|
"bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]",
|
||||||
|
defaultLabel: "양호",
|
||||||
|
},
|
||||||
|
excellent: {
|
||||||
|
className:
|
||||||
|
"bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)] border-[var(--color-status-info-border)]",
|
||||||
|
defaultLabel: "우수",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
className: "bg-neutral-10 text-neutral-80 border-neutral-20",
|
||||||
|
defaultLabel: "미확인",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SeverityBadge({ severity, label }: SeverityBadgeProps) {
|
||||||
|
const { className, defaultLabel } = config[severity];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full label-12 font-medium px-3 py-1 border ${className}`}
|
||||||
|
>
|
||||||
|
{label ?? defaultLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
import { SeverityBadge } from "@/components/badge/SeverityBadge";
|
||||||
|
import { ScoreRing } from "@/components/rating/ScoreRing";
|
||||||
|
import type { Severity } from "@/types/severity";
|
||||||
|
|
||||||
|
export type ChannelScoreCardProps = {
|
||||||
|
channel: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
/** 아이콘·점수 링에 쓰는 브랜드/강조 색 (없으면 링은 점수대비 자동색) */
|
||||||
|
accentColor?: string;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
headline: string;
|
||||||
|
severity: Severity;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelScoreCard({
|
||||||
|
channel,
|
||||||
|
icon,
|
||||||
|
accentColor,
|
||||||
|
score,
|
||||||
|
maxScore,
|
||||||
|
headline,
|
||||||
|
severity,
|
||||||
|
className = "",
|
||||||
|
style,
|
||||||
|
}: ChannelScoreCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-4 text-center flex flex-col items-center gap-3 animate-fade-in-up ${className}`.trim()}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-xl bg-neutral-10 flex items-center justify-center shrink-0 [&_svg]:block ${accentColor ? "" : "text-neutral-60"}`}
|
||||||
|
style={accentColor ? { color: accentColor } : undefined}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<p className="body-14-medium text-navy-900 break-keep px-1">{channel}</p>
|
||||||
|
<ScoreRing score={score} maxScore={maxScore} size={60} color={accentColor} className="gap-1" />
|
||||||
|
<p className="label-12 text-neutral-60 line-clamp-2 leading-relaxed min-h-8 break-keep px-1">{headline}</p>
|
||||||
|
<SeverityBadge severity={severity} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
|
export type InfoStatCardProps = {
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InfoStatCard({ icon, label, value, className = "", style }: InfoStatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl bg-white border border-neutral-20 shadow-sm p-5 animate-fade-in-up ${className}`.trim()}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded-lg bg-neutral-10 flex items-center justify-center text-neutral-60">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="label-12 text-neutral-60 uppercase tracking-wide break-keep">{label}</p>
|
||||||
|
<p className="title-18 text-navy-900 mt-1 break-keep">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import ArrowDownIcon from "@/assets/icons/arrow-down.svg?react";
|
||||||
|
import ArrowUpIcon from "@/assets/icons/arrow-up.svg?react";
|
||||||
|
import MinusIcon from "@/assets/icons/minus.svg?react";
|
||||||
|
|
||||||
|
export type MetricCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
subtext?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
trend?: "up" | "down" | "neutral";
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendConfig = {
|
||||||
|
up: { Icon: ArrowUpIcon, className: "text-violet-600" },
|
||||||
|
down: { Icon: ArrowDownIcon, className: "text-[var(--color-status-critical-text)]" },
|
||||||
|
neutral: { Icon: MinusIcon, className: "text-neutral-60" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function MetricCard({ label, value, subtext, icon, trend }: MetricCardProps) {
|
||||||
|
const TrendGlyph = trend ? trendConfig[trend].Icon : null;
|
||||||
|
const trendColor = trend ? trendConfig[trend].className : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-neutral-20 shadow-sm bg-white p-5 relative animate-fade-in-up">
|
||||||
|
{icon ? <div className="absolute top-4 right-4 text-neutral-40 [&_svg]:block">{icon}</div> : null}
|
||||||
|
<p className="body-14 text-neutral-60 mb-1 break-keep">{label}</p>
|
||||||
|
<div className="flex items-end gap-2 min-w-0">
|
||||||
|
<span className="text-3xl font-bold text-navy-900 break-keep">{value}</span>
|
||||||
|
{trend && TrendGlyph ? (
|
||||||
|
<span className={`mb-1 ${trendColor}`}>
|
||||||
|
<TrendGlyph width={18} height={18} aria-hidden />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{subtext ? <p className="label-12 text-neutral-60 mt-1 break-keep">{subtext}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||||
|
import XCircleIcon from "@/assets/report/x-circle.svg?react";
|
||||||
|
|
||||||
|
export type PixelInstallCardProps = {
|
||||||
|
name: string;
|
||||||
|
installed: boolean;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PixelInstallCard({ name, installed, details }: PixelInstallCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-xl p-3 min-w-0 ${
|
||||||
|
installed
|
||||||
|
? "bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)]"
|
||||||
|
: "bg-[var(--color-status-critical-bg)] border border-[var(--color-status-critical-border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{installed ? (
|
||||||
|
<CheckCircleIcon width={16} height={16} className="text-[var(--color-status-good-dot)] shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<XCircleIcon width={16} height={16} className="text-[var(--color-status-critical-dot)] shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p
|
||||||
|
className={`body-14-medium break-keep ${
|
||||||
|
installed ? "text-[var(--color-status-good-text)]" : "text-[var(--color-status-critical-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
{details ? (
|
||||||
|
<p className="label-12 text-neutral-60 break-keep min-w-0">{details}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
export type TagChipListProps = {
|
||||||
|
tags: string[];
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
/** 기본 `animation-delay-400`. 빈 문자열이면 지연 없음 */
|
||||||
|
entranceDelayClass?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TagChipList({
|
||||||
|
tags,
|
||||||
|
title,
|
||||||
|
className = "",
|
||||||
|
entranceDelayClass = "animation-delay-400",
|
||||||
|
}: TagChipListProps) {
|
||||||
|
if (!tags.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`animate-fade-in-up ${entranceDelayClass} ${className}`.trim()}>
|
||||||
|
{title ? <p className="body-14-medium text-neutral-80 mb-3 break-keep">{title}</p> : null}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-white/60 backdrop-blur-sm border border-neutral-20 px-3 py-1 body-14-medium text-neutral-80 break-keep"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
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";
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ConsolidationCalloutProps = {
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 통합·전략 권고 등 강조 CTA 블록 — 리포트 채널 섹션 공통 */
|
||||||
|
export function ConsolidationCallout({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: ConsolidationCalloutProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`mt-8 rounded-2xl bg-gradient-to-r from-violet-700 to-navy-950 p-6 md:p-8 text-white animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{icon ? (
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white/10 flex items-center justify-center shrink-0 text-white [&_svg]:block">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h4 className="font-serif headline-24 mb-2 break-keep">{title}</h4>
|
||||||
|
<div className="body-14 text-lavender-200 leading-relaxed break-keep">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type HighlightPanelProps = {
|
||||||
|
title: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HighlightPanel({ title, icon, className = "", children }: HighlightPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl bg-gradient-to-r from-violet-700/5 to-navy-950/5 border border-lavender-200 p-6 animate-fade-in-up animation-delay-300 ${className}`.trim()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
{icon ? <span className="text-violet-700 shrink-0 [&_svg]:block">{icon}</span> : null}
|
||||||
|
<h3 className="font-serif headline-20 text-navy-900">{title}</h3>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export type ScoreRingProps = {
|
||||||
|
score: number;
|
||||||
|
maxScore?: number;
|
||||||
|
size?: number;
|
||||||
|
label?: string;
|
||||||
|
/** 링 색 (브랜드 등). 없으면 점수 구간별 자동 색 */
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
scoreClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function scoreStrokeColor(score: number, maxScore: number): string {
|
||||||
|
const pct = (score / maxScore) * 100;
|
||||||
|
if (pct <= 40) return "#D4889A";
|
||||||
|
if (pct <= 60) return "#7A84D4";
|
||||||
|
if (pct <= 80) return "#9B8AD4";
|
||||||
|
return "#6C5CE7";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScoreRing({
|
||||||
|
score,
|
||||||
|
maxScore = 100,
|
||||||
|
size = 120,
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
className = "",
|
||||||
|
scoreClassName,
|
||||||
|
}: ScoreRingProps) {
|
||||||
|
const strokeWidth = size <= 72 ? 5 : 8;
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const progress = Math.min(score / maxScore, 1);
|
||||||
|
const targetOffset = circumference * (1 - progress);
|
||||||
|
const resolvedColor = color ?? scoreStrokeColor(score, maxScore);
|
||||||
|
|
||||||
|
const [dashOffset, setDashOffset] = useState(circumference);
|
||||||
|
|
||||||
|
const defaultScoreClass =
|
||||||
|
size <= 72 ? "text-sm font-bold text-navy-900" : "text-2xl font-bold text-navy-900";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDashOffset(circumference);
|
||||||
|
const id = requestAnimationFrame(() => setDashOffset(targetOffset));
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, [circumference, targetOffset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col items-center gap-2 ${className}`.trim()}>
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
|
className="-rotate-90"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
className="stroke-neutral-20"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={resolvedColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={dashOffset}
|
||||||
|
style={{ transition: "stroke-dashoffset 1s ease-out" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className={scoreClassName ?? defaultScoreClass}>{score}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label ? <span className="body-14 text-neutral-60 text-center">{label}</span> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
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 연결 없음, 트래킹만 존재" },
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
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 기반 리타겟 광고 전용 채널로 운영하는 것이 효율적입니다.",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
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건" },
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
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: "전화 + 카카오톡 상담",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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 (논현동)",
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
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: "지역 검색 노출 필수" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 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"];
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Severity } from "@/types/severity";
|
||||||
|
|
||||||
|
export type ChannelScore = {
|
||||||
|
channel: string;
|
||||||
|
icon: string;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
status: Severity;
|
||||||
|
headline: string;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import type { Severity } from "@/types/severity";
|
||||||
|
|
||||||
|
export type DiagnosisItem = {
|
||||||
|
category: string;
|
||||||
|
detail: string;
|
||||||
|
severity: Severity;
|
||||||
|
evidenceIds?: string[];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export type KpiMetric = {
|
||||||
|
metric: string;
|
||||||
|
current: string;
|
||||||
|
target3Month: string;
|
||||||
|
target12Month: string;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
export type ReportOverviewData = {
|
||||||
|
clinicName: string;
|
||||||
|
clinicNameEn: string;
|
||||||
|
overallScore: number;
|
||||||
|
date: string;
|
||||||
|
targetUrl: string;
|
||||||
|
location: string;
|
||||||
|
logoImage?: string;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
export type RoadmapTask = {
|
||||||
|
task: string;
|
||||||
|
completed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoadmapMonth = {
|
||||||
|
month: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
tasks: RoadmapTask[];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { MOCK_KPI_METRICS } from "@/features/report/constants/mock_kpi";
|
||||||
|
import type { KpiMetric } from "@/features/report/types/kpiDashboard";
|
||||||
|
import { KpiMetricsTable } from "@/features/report/ui/kpi/KpiMetricsTable";
|
||||||
|
import { KpiTransformationCtaCard } from "@/features/report/ui/kpi/KpiTransformationCtaCard";
|
||||||
|
|
||||||
|
type ReportKpiSectionProps = {
|
||||||
|
metrics?: KpiMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportKpiSection({ metrics = MOCK_KPI_METRICS }: ReportKpiSectionProps) {
|
||||||
|
return (
|
||||||
|
<PageSection id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표">
|
||||||
|
<KpiMetricsTable metrics={metrics} />
|
||||||
|
<KpiTransformationCtaCard />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { MOCK_OTHER_CHANNELS_REPORT } from "@/features/report/constants/mock_other_channels";
|
||||||
|
import type { OtherChannelsReport } from "@/types/otherChannels";
|
||||||
|
import { OtherChannelsList } from "@/features/report/ui/otherChannels/OtherChannelsList";
|
||||||
|
import { WebsiteTechAuditBlock } from "@/features/report/ui/otherChannels/WebsiteTechAuditBlock";
|
||||||
|
|
||||||
|
type ReportOtherChannelsSectionProps = {
|
||||||
|
data?: OtherChannelsReport;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportOtherChannelsSection({ data = MOCK_OTHER_CHANNELS_REPORT }: ReportOtherChannelsSectionProps) {
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="other-channels"
|
||||||
|
title="Other Channels & Website"
|
||||||
|
subtitle="기타 채널 및 웹사이트 기술 진단"
|
||||||
|
>
|
||||||
|
<OtherChannelsList channels={data.channels} />
|
||||||
|
<WebsiteTechAuditBlock website={data.website} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { MOCK_REPORT_OVERVIEW } from "@/features/report/constants/mock_report_overview";
|
||||||
|
import type { ReportOverviewData } from "@/features/report/types/reportOverview";
|
||||||
|
import { OverviewHeroBlobs } from "@/features/report/ui/overview/OverviewHeroBlobs";
|
||||||
|
import { OverviewHeroColumn } from "@/features/report/ui/overview/OverviewHeroColumn";
|
||||||
|
import { OverviewScorePanel } from "@/features/report/ui/overview/OverviewScorePanel";
|
||||||
|
import { OVERVIEW_SECTION_BG_CLASS } from "@/features/report/ui/overview/overviewSectionStyles";
|
||||||
|
|
||||||
|
type ReportOverviewSectionProps = {
|
||||||
|
data?: ReportOverviewData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportOverviewSection({ data = MOCK_REPORT_OVERVIEW }: ReportOverviewSectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="header"
|
||||||
|
aria-label="개요"
|
||||||
|
className={`relative overflow-hidden scroll-mt-36 py-16 md:py-20 px-6 ${OVERVIEW_SECTION_BG_CLASS}`}
|
||||||
|
>
|
||||||
|
<OverviewHeroBlobs />
|
||||||
|
|
||||||
|
<div className="relative max-w-7xl mx-auto">
|
||||||
|
<div className="flex flex-col md:flex-row items-center md:items-start justify-between gap-10">
|
||||||
|
<OverviewHeroColumn data={data} />
|
||||||
|
<OverviewScorePanel overallScore={data.overallScore} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { MOCK_ROADMAP } from "@/features/report/constants/mock_roadmap";
|
||||||
|
import type { RoadmapMonth } from "@/features/report/types/roadmap";
|
||||||
|
import { RoadmapMonthsGrid } from "@/features/report/ui/roadmap/RoadmapMonthsGrid";
|
||||||
|
|
||||||
|
type ReportRoadmapSectionProps = {
|
||||||
|
months?: RoadmapMonth[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportRoadmapSection({ months = MOCK_ROADMAP }: ReportRoadmapSectionProps) {
|
||||||
|
return (
|
||||||
|
<PageSection id="roadmap" title="90-Day Roadmap" subtitle="실행 로드맵">
|
||||||
|
<RoadmapMonthsGrid months={months} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { MOCK_TRANSFORMATION } from "@/features/report/constants/mock_transformation";
|
||||||
|
import type { TransformationProposal } from "@/features/report/types/transformationProposal";
|
||||||
|
import { TransformationTabbedView } from "@/features/report/ui/transformation/TransformationTabbedView";
|
||||||
|
|
||||||
|
type ReportTransformationSectionProps = {
|
||||||
|
data?: TransformationProposal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportTransformationSection({ data = MOCK_TRANSFORMATION }: ReportTransformationSectionProps) {
|
||||||
|
return (
|
||||||
|
<PageSection
|
||||||
|
id="transformation"
|
||||||
|
title="Transformation Proposal"
|
||||||
|
subtitle="As-Is → To-Be 전환 제안"
|
||||||
|
>
|
||||||
|
<TransformationTabbedView data={data} />
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { TagChipList } from "@/components/chip/TagChipList";
|
||||||
|
import { DiagnosisRow } from "@/components/diagnosis/DiagnosisRow";
|
||||||
|
import { PageSection } from "@/components/section/PageSection";
|
||||||
|
import { MOCK_YOUTUBE_AUDIT } from "@/features/report/constants/mock_youtube_audit";
|
||||||
|
import type { YouTubeAudit } from "@/features/report/types/youtubeAudit";
|
||||||
|
import { YouTubeChannelInfoCard } from "@/features/report/ui/youtube/YouTubeChannelInfoCard";
|
||||||
|
import { YouTubeMetricsGrid } from "@/features/report/ui/youtube/YouTubeMetricsGrid";
|
||||||
|
import { YouTubeTopVideosBlock } from "@/features/report/ui/youtube/YouTubeTopVideosBlock";
|
||||||
|
|
||||||
|
type ReportYouTubeSectionProps = {
|
||||||
|
data?: YouTubeAudit;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportYouTubeSection({ data = MOCK_YOUTUBE_AUDIT }: ReportYouTubeSectionProps) {
|
||||||
|
return (
|
||||||
|
<PageSection id="youtube-audit" title="YouTube Analysis" subtitle="유튜브 채널 분석">
|
||||||
|
<YouTubeMetricsGrid data={data} />
|
||||||
|
<YouTubeChannelInfoCard data={data} />
|
||||||
|
|
||||||
|
{data.playlists.length > 0 ? (
|
||||||
|
<div className="mb-8">
|
||||||
|
<TagChipList title="재생목록" tags={data.playlists} entranceDelayClass="animation-delay-200" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<YouTubeTopVideosBlock videos={data.topVideos} />
|
||||||
|
|
||||||
|
{data.diagnosis.length > 0 ? (
|
||||||
|
<div className="rounded-2xl bg-white border border-neutral-20 shadow-sm p-6 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { ChannelScoreCard } from "@/components/card/ChannelScoreCard";
|
||||||
|
import type { ChannelScore } from "@/features/report/types/channelScore";
|
||||||
|
import {
|
||||||
|
channelScoreAccentColor,
|
||||||
|
renderChannelScoreIcon,
|
||||||
|
} from "@/features/report/ui/channels/channelScoreIcons";
|
||||||
|
|
||||||
|
export type ChannelScoreGridProps = {
|
||||||
|
channels: ChannelScore[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChannelScoreGrid({ channels }: ChannelScoreGridProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
|
{channels.map((ch, i) => {
|
||||||
|
const iconKey = ch.icon?.toLowerCase() ?? "";
|
||||||
|
return (
|
||||||
|
<ChannelScoreCard
|
||||||
|
key={ch.channel}
|
||||||
|
channel={ch.channel}
|
||||||
|
icon={renderChannelScoreIcon(iconKey)}
|
||||||
|
accentColor={channelScoreAccentColor(iconKey)}
|
||||||
|
score={ch.score}
|
||||||
|
maxScore={ch.maxScore}
|
||||||
|
headline={ch.headline}
|
||||||
|
severity={ch.status}
|
||||||
|
style={{ animationDelay: `${i * 80}ms` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import type { ComponentType, SVGProps } from "react";
|
||||||
|
import ChannelFacebookIcon from "@/assets/icons/channel-facebook.svg?react";
|
||||||
|
import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react";
|
||||||
|
import ChannelSearchIcon from "@/assets/icons/channel-search.svg?react";
|
||||||
|
import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
|
||||||
|
import StarIcon from "@/assets/icons/star.svg?react";
|
||||||
|
import GlobeIcon from "@/assets/report/globe.svg?react";
|
||||||
|
|
||||||
|
type SvgIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
|
||||||
|
const CHANNEL_ICONS: Record<string, SvgIcon> = {
|
||||||
|
youtube: ChannelYoutubeIcon,
|
||||||
|
instagram: ChannelInstagramIcon,
|
||||||
|
facebook: ChannelFacebookIcon,
|
||||||
|
star: StarIcon,
|
||||||
|
globe: GlobeIcon,
|
||||||
|
search: ChannelSearchIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHANNEL_ACCENT: Record<string, string> = {
|
||||||
|
facebook: "#1877F2",
|
||||||
|
instagram: "#E1306C",
|
||||||
|
youtube: "#FF0000",
|
||||||
|
globe: "#6B2D8B",
|
||||||
|
star: "#6B2D8B",
|
||||||
|
search: "#606060",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function renderChannelScoreIcon(iconKey: string) {
|
||||||
|
const k = iconKey?.toLowerCase() ?? "";
|
||||||
|
const Cmp = CHANNEL_ICONS[k] ?? GlobeIcon;
|
||||||
|
return <Cmp width={20} height={20} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function channelScoreAccentColor(iconKey: string): string | undefined {
|
||||||
|
const k = iconKey?.toLowerCase() ?? "";
|
||||||
|
return CHANNEL_ACCENT[k];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { TagChipList } from "@/components/chip/TagChipList";
|
||||||
|
|
||||||
|
export type ClinicCertificationsBlockProps = {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClinicCertificationsBlock({ tags }: ClinicCertificationsBlockProps) {
|
||||||
|
if (tags.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-8">
|
||||||
|
<TagChipList title="인증 및 자격" tags={tags} entranceDelayClass="" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { InfoStatCard } from "@/components/card/InfoStatCard";
|
||||||
|
import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot";
|
||||||
|
import { buildClinicSnapshotStatRows } from "@/features/report/ui/clinic/clinicSnapshotStatRows";
|
||||||
|
|
||||||
|
export type ClinicInfoStatGridProps = {
|
||||||
|
data: ClinicSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClinicInfoStatGrid({ data }: ClinicInfoStatGridProps) {
|
||||||
|
const rows = buildClinicSnapshotStatRows(data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
|
{rows.map((field, i) => (
|
||||||
|
<InfoStatCard
|
||||||
|
key={field.label}
|
||||||
|
icon={field.icon}
|
||||||
|
label={field.label}
|
||||||
|
value={field.value}
|
||||||
|
style={{ animationDelay: `${i * 50}ms` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import AwardIcon from "@/assets/icons/award.svg?react";
|
||||||
|
import { HighlightPanel } from "@/components/panel/HighlightPanel";
|
||||||
|
import { StarRatingDisplay } from "@/components/rating/StarRatingDisplay";
|
||||||
|
import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot";
|
||||||
|
|
||||||
|
export type ClinicLeadDoctorPanelProps = {
|
||||||
|
data: ClinicSnapshot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClinicLeadDoctorPanel({ data }: ClinicLeadDoctorPanelProps) {
|
||||||
|
return (
|
||||||
|
<HighlightPanel title="대표 원장" icon={<AwardIcon aria-hidden />}>
|
||||||
|
<p className="title-20 text-navy-900 mb-1 break-keep">{data.leadDoctor.name}</p>
|
||||||
|
<p className="body-14 text-neutral-70 mb-3 break-keep">{data.leadDoctor.credentials}</p>
|
||||||
|
<StarRatingDisplay rating={data.leadDoctor.rating} reviewCount={data.leadDoctor.reviewCount} />
|
||||||
|
</HighlightPanel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import PhoneIcon from "@/assets/icons/phone.svg?react";
|
||||||
|
import StarIcon from "@/assets/icons/star.svg?react";
|
||||||
|
import UsersIcon from "@/assets/icons/users.svg?react";
|
||||||
|
import CalendarIcon from "@/assets/report/calendar.svg?react";
|
||||||
|
import GlobeIcon from "@/assets/report/globe.svg?react";
|
||||||
|
import MapPinIcon from "@/assets/report/map-pin.svg?react";
|
||||||
|
import type { ClinicSnapshot } from "@/features/report/types/clinicSnapshot";
|
||||||
|
import { formatCompactNumber } from "@/lib/formatNumber";
|
||||||
|
|
||||||
|
export type ClinicStatRow = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildClinicSnapshotStatRows(data: ClinicSnapshot): ClinicStatRow[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "개원",
|
||||||
|
value: `${data.established} (${data.yearsInBusiness}년)`,
|
||||||
|
icon: <CalendarIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "의료진",
|
||||||
|
value: `${data.staffCount}명`,
|
||||||
|
icon: <UsersIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "강남언니 평점",
|
||||||
|
value: `${data.overallRating} / 5.0`,
|
||||||
|
icon: <StarIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "리뷰 수",
|
||||||
|
value: formatCompactNumber(data.totalReviews),
|
||||||
|
icon: <StarIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "시술 가격대",
|
||||||
|
value: `${data.priceRange.min} ~ ${data.priceRange.max} ${data.priceRange.currency}`.trim(),
|
||||||
|
icon: <GlobeIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "위치",
|
||||||
|
value: `${data.location} (${data.nearestStation})`,
|
||||||
|
icon: <MapPinIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "전화",
|
||||||
|
value: data.phone,
|
||||||
|
icon: <PhoneIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "도메인",
|
||||||
|
value: data.domain,
|
||||||
|
icon: <GlobeIcon width={16} height={16} aria-hidden />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react";
|
||||||
|
import type { DiagnosisItem } from "@/features/report/types/diagnosis";
|
||||||
|
import { problemDiagnosisSeverityDotClass } from "@/features/report/ui/diagnosis/severityDotClass";
|
||||||
|
|
||||||
|
export type ProblemDiagnosisCardProps = {
|
||||||
|
item: DiagnosisItem;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProblemDiagnosisCard({ item, index }: ProblemDiagnosisCardProps) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="bg-white/10 backdrop-blur-sm border border-white/10 rounded-2xl p-6 relative overflow-hidden card-shadow animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${index * 80}ms` }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-4 right-4" aria-hidden>
|
||||||
|
<span className={`block w-3 h-3 rounded-full ${problemDiagnosisSeverityDotClass(item.severity)}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3 pr-6">
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center mt-0.5 [&_svg]:block">
|
||||||
|
<AlertCircleIcon width={16} height={16} className="text-lavender-200" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="title-18 text-white mb-2 break-keep">{item.category}</p>
|
||||||
|
<p className="body-14 text-lavender-200 leading-relaxed break-keep">{item.detail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { Severity } from "@/types/severity";
|
||||||
|
|
||||||
|
/** 다크 섹션 카드 우측 상단 심각도 점 — `index.css` 시맨틱 dot 토큰 */
|
||||||
|
export function problemDiagnosisSeverityDotClass(severity: Severity): string {
|
||||||
|
const map: Record<Severity, string> = {
|
||||||
|
critical: "bg-[var(--color-status-critical-dot)]",
|
||||||
|
warning: "bg-[var(--color-status-warning-dot)]",
|
||||||
|
good: "bg-[var(--color-status-good-dot)]",
|
||||||
|
excellent: "bg-[var(--color-status-info-dot)]",
|
||||||
|
unknown: "bg-neutral-60",
|
||||||
|
};
|
||||||
|
return map[severity];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
|
||||||
|
import EyeIcon from "@/assets/icons/eye.svg?react";
|
||||||
|
import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
|
||||||
|
import AlertTriangleIcon from "@/assets/report/alert-triangle.svg?react";
|
||||||
|
import CheckCircleIcon from "@/assets/report/check-circle.svg?react";
|
||||||
|
import FacebookMarkIcon from "@/assets/report/facebook-mark.svg?react";
|
||||||
|
import ImageIcon from "@/assets/report/image.svg?react";
|
||||||
|
import Link2Icon from "@/assets/report/link-2.svg?react";
|
||||||
|
import MessageCircleIcon from "@/assets/report/message-circle.svg?react";
|
||||||
|
import type { FacebookPage } from "@/features/report/types/facebookAudit";
|
||||||
|
import { facebookLangBadgeClass } from "@/features/report/ui/facebook/langBadgeClass";
|
||||||
|
import { formatCompactNumber } from "@/lib/formatNumber";
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
|
export type FacebookPageCardProps = {
|
||||||
|
page: FacebookPage;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FacebookPageCard({ page, index }: FacebookPageCardProps) {
|
||||||
|
const isKR = page.language === "KR";
|
||||||
|
const isLogoMismatch = page.logo.includes("불일치");
|
||||||
|
const isLowFollowers = page.followers < 500;
|
||||||
|
const lowEngagement = page.engagement?.includes("0~3") ?? false;
|
||||||
|
const domainMismatch = page.linkedDomain?.includes("다름") ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`rounded-2xl border p-6 card-shadow animate-fade-in-up ${
|
||||||
|
isKR && isLowFollowers
|
||||||
|
? "bg-[var(--color-status-critical-bg)]/30 border-[var(--color-status-critical-border)]/60"
|
||||||
|
: "bg-white border-neutral-20"
|
||||||
|
}`}
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||||
|
<span
|
||||||
|
className={`label-12 font-medium px-3 py-1 rounded-full break-keep ${facebookLangBadgeClass(page.language)}`}
|
||||||
|
>
|
||||||
|
{page.label}
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-[#1877F2] flex items-center justify-center text-white shrink-0 [&_svg]:block">
|
||||||
|
<FacebookMarkIcon width={16} height={16} aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 justify-end">
|
||||||
|
{isKR && isLowFollowers ? (
|
||||||
|
<span className="label-12 font-medium px-3 py-1 rounded-full bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] break-keep">
|
||||||
|
방치 상태
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{page.hasWhatsApp ? (
|
||||||
|
<span className="label-12 font-medium px-3 py-1 rounded-full bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] break-keep">
|
||||||
|
WhatsApp 연결
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="title-18 text-navy-900 mb-1 break-keep">{page.pageName}</h3>
|
||||||
|
<p className="label-12 text-neutral-60 mb-4 break-keep">{page.category}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-5">
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">팔로워</p>
|
||||||
|
<p
|
||||||
|
className={`title-18 ${
|
||||||
|
isLowFollowers ? "text-[var(--color-status-critical-text)]" : "text-navy-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCompactNumber(page.followers)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">리뷰</p>
|
||||||
|
<p
|
||||||
|
className={`title-18 ${
|
||||||
|
page.reviews === 0 ? "text-[var(--color-status-critical-text)]" : "text-navy-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.reviews}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">팔로잉</p>
|
||||||
|
<p className="title-18 text-navy-900">{formatCompactNumber(page.following)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-5 body-14">
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1 border-b border-neutral-20 last:border-0">
|
||||||
|
<span className="text-neutral-60 flex items-center gap-2 shrink-0 break-keep">
|
||||||
|
<EyeIcon width={13} height={13} className="shrink-0" aria-hidden />
|
||||||
|
최근 게시물
|
||||||
|
</span>
|
||||||
|
<span className="body-14-medium text-navy-900 text-right break-keep">{page.recentPostAge}</span>
|
||||||
|
</div>
|
||||||
|
{page.postFrequency ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1 border-b border-neutral-20 last:border-0">
|
||||||
|
<span className="text-neutral-60 flex items-center gap-2 shrink-0 break-keep">
|
||||||
|
<TrendingUpIcon width={13} height={13} className="shrink-0" aria-hidden />
|
||||||
|
게시 빈도
|
||||||
|
</span>
|
||||||
|
<span className="body-14-medium text-navy-900 text-right break-keep min-w-0">{page.postFrequency}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{page.topContentType ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1 border-b border-neutral-20 last:border-0">
|
||||||
|
<span className="text-neutral-60 flex items-center gap-2 shrink-0 break-keep">
|
||||||
|
<ImageIcon width={13} height={13} className="shrink-0" aria-hidden />
|
||||||
|
콘텐츠 유형
|
||||||
|
</span>
|
||||||
|
<span className="body-14-medium text-navy-900 text-right text-xs max-w-[180px] min-w-0 break-keep">
|
||||||
|
{page.topContentType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{page.engagement ? (
|
||||||
|
<div className="flex items-center justify-between gap-2 py-1 border-b border-neutral-20 last:border-0">
|
||||||
|
<span className="text-neutral-60 flex items-center gap-2 shrink-0 break-keep">
|
||||||
|
<MessageCircleIcon width={13} height={13} className="shrink-0" aria-hidden />
|
||||||
|
참여율
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`body-14-medium text-right text-xs max-w-[180px] min-w-0 break-keep ${
|
||||||
|
lowEngagement ? "text-[var(--color-status-critical-text)]" : "text-navy-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.engagement}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-xl p-4 mb-4 ${
|
||||||
|
isLogoMismatch
|
||||||
|
? "bg-[var(--color-status-critical-bg)] border border-[var(--color-status-critical-border)]"
|
||||||
|
: "bg-[var(--color-status-good-bg)] border border-[var(--color-status-good-border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
{isLogoMismatch ? (
|
||||||
|
<AlertTriangleIcon
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="text-[var(--color-status-critical-text)] shrink-0 mt-0.5"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CheckCircleIcon
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
className="text-[var(--color-status-good-dot)] shrink-0 mt-0.5"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`body-14-medium break-keep ${
|
||||||
|
isLogoMismatch ? "text-[var(--color-status-critical-text)]" : "text-[var(--color-status-good-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
로고 {page.logo}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`label-12 leading-relaxed ml-6 break-keep ${
|
||||||
|
isLogoMismatch ? "text-[var(--color-status-critical-text)]" : "text-[var(--color-status-good-text)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.logoDescription}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Link2Icon width={12} height={12} className="text-neutral-60 shrink-0" aria-hidden />
|
||||||
|
<p className="label-12 text-neutral-60 uppercase tracking-wide break-keep">연결 도메인</p>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`body-14 font-mono break-keep ${
|
||||||
|
domainMismatch ? "text-[var(--color-status-warning-text)]" : "text-neutral-70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.linkedDomain || page.link}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 mb-4">
|
||||||
|
<p className="label-12 text-neutral-60 uppercase tracking-wide mb-1 break-keep">Bio</p>
|
||||||
|
<p className="body-14 text-neutral-70 italic break-keep">"{page.bio}"</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={safeUrl(page.url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 label-12 text-violet-700 hover:underline break-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon aria-hidden />
|
||||||
|
<span className="break-keep">{page.url}</span>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { FacebookPage } from "@/features/report/types/facebookAudit";
|
||||||
|
|
||||||
|
export function facebookLangBadgeClass(language: FacebookPage["language"]) {
|
||||||
|
return language === "KR"
|
||||||
|
? "bg-neutral-10 text-neutral-80"
|
||||||
|
: "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)]";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { ReportChannelsSection } from "@/features/report/ui/ReportChannelsSection";
|
||||||
|
import { ReportClinicSection } from "@/features/report/ui/ReportClinicSection";
|
||||||
|
import { ReportDiagnosisSection } from "@/features/report/ui/ReportDiagnosisSection";
|
||||||
|
import { ReportFacebookSection } from "@/features/report/ui/ReportFacebookSection";
|
||||||
|
import { ReportInstagramSection } from "@/features/report/ui/ReportInstagramSection";
|
||||||
|
import { ReportKpiSection } from "@/features/report/ui/ReportKpiSection";
|
||||||
|
import { ReportOtherChannelsSection } from "@/features/report/ui/ReportOtherChannelsSection";
|
||||||
|
import { ReportOverviewSection } from "@/features/report/ui/ReportOverviewSection";
|
||||||
|
import { ReportRoadmapSection } from "@/features/report/ui/ReportRoadmapSection";
|
||||||
|
import { ReportTransformationSection } from "@/features/report/ui/ReportTransformationSection";
|
||||||
|
import { ReportYouTubeSection } from "@/features/report/ui/ReportYouTubeSection";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ReportChannelsSection,
|
||||||
|
ReportClinicSection,
|
||||||
|
ReportDiagnosisSection,
|
||||||
|
ReportFacebookSection,
|
||||||
|
ReportInstagramSection,
|
||||||
|
ReportKpiSection,
|
||||||
|
ReportOtherChannelsSection,
|
||||||
|
ReportOverviewSection,
|
||||||
|
ReportRoadmapSection,
|
||||||
|
ReportTransformationSection,
|
||||||
|
ReportYouTubeSection,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react";
|
||||||
|
import ChannelInstagramIcon from "@/assets/icons/channel-instagram.svg?react";
|
||||||
|
import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
|
||||||
|
import { TagChipList } from "@/components/chip/TagChipList";
|
||||||
|
import type { InstagramAccount } from "@/features/report/types/instagramAudit";
|
||||||
|
import { instagramLangBadgeClass } from "@/features/report/ui/instagram/langBadgeClass";
|
||||||
|
import { formatCompactNumber } from "@/lib/formatNumber";
|
||||||
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
|
export type InstagramAccountCardProps = {
|
||||||
|
account: InstagramAccount;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InstagramAccountCard({ account, index }: InstagramAccountCardProps) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="rounded-2xl bg-white border border-neutral-20 p-6 card-shadow animate-fade-in-up"
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||||
|
<span
|
||||||
|
className={`label-12 font-medium px-3 py-1 rounded-full break-keep ${instagramLangBadgeClass(account.language)}`}
|
||||||
|
>
|
||||||
|
{account.label}
|
||||||
|
</span>
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-500 to-marketing-blush flex items-center justify-center text-white [&_svg]:block">
|
||||||
|
<ChannelInstagramIcon width={16} height={16} aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="title-18 text-navy-900 mb-1 break-keep">{account.handle}</h3>
|
||||||
|
<p className="label-12 text-neutral-60 mb-4 break-keep">{account.category}</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={safeUrl(account.profileLink)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 label-12 text-violet-700 hover:underline mb-4 break-all cursor-pointer"
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon aria-hidden />
|
||||||
|
<span className="break-keep">{account.profileLink}</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">게시물</p>
|
||||||
|
<p className="title-18 text-navy-900">{formatCompactNumber(account.posts)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">팔로워</p>
|
||||||
|
<p className="title-18 text-navy-900">{formatCompactNumber(account.followers)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3 text-center">
|
||||||
|
<p className="label-12 text-neutral-60 break-keep">팔로잉</p>
|
||||||
|
<p className="title-18 text-navy-900">{formatCompactNumber(account.following)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex items-center justify-between gap-2 body-14">
|
||||||
|
<span className="text-neutral-60 shrink-0 break-keep">콘텐츠 포맷</span>
|
||||||
|
<span className="body-14-medium text-navy-900 text-right break-keep min-w-0">{account.contentFormat}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 body-14">
|
||||||
|
<span className="text-neutral-60 shrink-0 break-keep">릴스 수</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
account.reelsCount === 0
|
||||||
|
? "body-14-medium text-[var(--color-status-critical-text)] inline-flex items-center gap-1 break-keep"
|
||||||
|
: "body-14-medium text-navy-900 break-keep"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{account.reelsCount === 0 ? (
|
||||||
|
<>
|
||||||
|
<AlertCircleIcon className="text-[var(--color-status-critical-dot)] shrink-0" aria-hidden />
|
||||||
|
0 (미운영)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
account.reelsCount
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="label-12 text-neutral-60 mb-1 break-keep">프로필 이미지</p>
|
||||||
|
<p className="body-14 text-neutral-70 mb-4 break-keep">{account.profilePhoto}</p>
|
||||||
|
|
||||||
|
{account.highlights.length > 0 ? (
|
||||||
|
<div className="mb-4">
|
||||||
|
<TagChipList tags={account.highlights} title="하이라이트" entranceDelayClass="" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{account.bio ? (
|
||||||
|
<div className="rounded-xl bg-neutral-10 p-3">
|
||||||
|
<p className="label-12 text-neutral-60 mb-1 break-keep">Bio</p>
|
||||||
|
<p className="body-14 text-neutral-70 whitespace-pre-line break-keep">{account.bio}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import type { InstagramAccount } from "@/features/report/types/instagramAudit";
|
||||||
|
|
||||||
|
export function instagramLangBadgeClass(language: InstagramAccount["language"]) {
|
||||||
|
return language === "KR"
|
||||||
|
? "bg-neutral-10 text-neutral-80"
|
||||||
|
: "bg-[var(--color-status-info-bg)] text-[var(--color-status-info-text)]";
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { KpiMetric } from "@/features/report/types/kpiDashboard";
|
||||||
|
import { isKpiCurrentValueNegative } from "@/features/report/ui/kpi/kpiCurrentValueNegative";
|
||||||
|
|
||||||
|
export type KpiMetricsTableProps = {
|
||||||
|
metrics: KpiMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function KpiMetricsTable({ metrics }: KpiMetricsTableProps) {
|
||||||
|
if (metrics.length === 0) {
|
||||||
|
return <p className="body-14 text-neutral-60 break-keep">등록된 KPI가 없습니다.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl overflow-hidden border border-neutral-20 mb-10 card-shadow animate-fade-in-up">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="min-w-[720px]">
|
||||||
|
<div className="grid grid-cols-4 bg-navy-900 text-white">
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Metric</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">Current</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">3-Month Target</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium break-keep">12-Month Target</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metrics.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={row.metric}
|
||||||
|
className={`grid grid-cols-4 animate-fade-in-up ${i % 2 === 0 ? "bg-white" : "bg-neutral-10"}`}
|
||||||
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 body-14-medium text-navy-900 break-keep min-w-0">{row.metric}</div>
|
||||||
|
<div
|
||||||
|
className={`px-6 py-4 body-14 font-semibold break-keep min-w-0 ${
|
||||||
|
isKpiCurrentValueNegative(row.current)
|
||||||
|
? "text-[var(--color-status-critical-text)]"
|
||||||
|
: "text-navy-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.current}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium text-[var(--color-status-good-text)] break-keep min-w-0">
|
||||||
|
{row.target3Month}
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 body-14-medium text-[var(--color-status-good-text)] break-keep min-w-0">
|
||||||
|
{row.target12Month}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import ArrowUpRightIcon from "@/assets/report/arrow-up-right.svg?react";
|
||||||
|
import DownloadIcon from "@/assets/report/download.svg?react";
|
||||||
|
import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
export function KpiTransformationCtaCard() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-2xl bg-gradient-to-r from-marketing-cream via-marketing-lilac to-marketing-ice p-8 md:p-12 text-center relative overflow-hidden animate-fade-in-up animation-delay-300"
|
||||||
|
data-cta-card
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-navy-950/10 flex items-center justify-center mx-auto mb-6 [&_svg]:block">
|
||||||
|
<TrendingUpIcon width={28} height={28} className="text-navy-950" aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-serif text-2xl md:text-3xl font-bold text-navy-950 mb-3 break-keep">
|
||||||
|
Start Your Transformation
|
||||||
|
</h3>
|
||||||
|
<p className="body-16 text-navy-950/60 mb-8 max-w-xl mx-auto break-keep">
|
||||||
|
INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-2 btn-primary font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
마케팅 기획
|
||||||
|
<ArrowUpRightIcon width={18} height={18} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 bg-white border border-neutral-20 text-navy-950 font-semibold px-8 py-4 rounded-full hover:bg-neutral-10 shadow-sm hover:shadow-md transition-all cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed break-keep"
|
||||||
|
>
|
||||||
|
<DownloadIcon width={18} height={18} aria-hidden />
|
||||||
|
리포트 다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** Current 컬럼 강조 — 저성과·미측정 값 */
|
||||||
|
export function isKpiCurrentValueNegative(value: string): boolean {
|
||||||
|
const lower = value.toLowerCase();
|
||||||
|
return (
|
||||||
|
lower === "0" ||
|
||||||
|
lower.includes("없음") ||
|
||||||
|
lower.includes("불가") ||
|
||||||
|
lower === "n/a" ||
|
||||||
|
lower.includes("측정 불가")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { OtherChannelRow } from "@/components/channel/OtherChannelRow";
|
||||||
|
import type { OtherChannel } from "@/types/otherChannels";
|
||||||
|
|
||||||
|
export type OtherChannelsListProps = {
|
||||||
|
channels: OtherChannel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OtherChannelsList({ channels }: OtherChannelsListProps) {
|
||||||
|
if (channels.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl bg-white border border-neutral-20 shadow-sm overflow-hidden mb-8 card-shadow animate-fade-in-up">
|
||||||
|
<div className="px-6 py-4 border-b border-neutral-20">
|
||||||
|
<h3 className="font-serif headline-24 text-navy-900 break-keep">기타 채널 현황</h3>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-neutral-20">
|
||||||
|
{channels.map((ch, i) => (
|
||||||
|
<OtherChannelRow
|
||||||
|
key={ch.name}
|
||||||
|
name={ch.name}
|
||||||
|
details={ch.details}
|
||||||
|
status={ch.status}
|
||||||
|
url={ch.url}
|
||||||
|
animationDelayMs={i * 50}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||