+
+
+
+ {page.label}
+
+
+
+
+
+
+ {isKR && isLowFollowers ? (
+
+ 방치 상태
+
+ ) : null}
+ {page.hasWhatsApp ? (
+
+ WhatsApp 연결
+
+ ) : null}
+
+
+
+ {page.pageName}
+ {page.category}
+
+
+
+
팔로워
+
+ {formatCompactNumber(page.followers)}
+
+
+
+
리뷰
+
+ {page.reviews}
+
+
+
+
팔로잉
+
{formatCompactNumber(page.following)}
+
+
+
+
+
+
+
+ 최근 게시물
+
+ {page.recentPostAge}
+
+ {page.postFrequency ? (
+
+
+
+ 게시 빈도
+
+ {page.postFrequency}
+
+ ) : null}
+ {page.topContentType ? (
+
+
+
+ 콘텐츠 유형
+
+
+ {page.topContentType}
+
+
+ ) : null}
+ {page.engagement ? (
+
+
+
+ 참여율
+
+
+ {page.engagement}
+
+
+ ) : null}
+
+
+
+
+ {isLogoMismatch ? (
+
+ ) : (
+
+ )}
+
+ 로고 {page.logo}
+
+
+
+ {page.logoDescription}
+
+
+
+
+
+
+ {page.linkedDomain || page.link}
+
+
+
+
+
+
+
+ {page.url}
+
+
+ );
+}
diff --git a/src/features/report/ui/facebook/langBadgeClass.ts b/src/features/report/ui/facebook/langBadgeClass.ts
new file mode 100644
index 0000000..c410979
--- /dev/null
+++ b/src/features/report/ui/facebook/langBadgeClass.ts
@@ -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)]";
+}
diff --git a/src/features/report/ui/index.tsx b/src/features/report/ui/index.tsx
new file mode 100644
index 0000000..78f7418
--- /dev/null
+++ b/src/features/report/ui/index.tsx
@@ -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,
+};
diff --git a/src/features/report/ui/instagram/InstagramAccountCard.tsx b/src/features/report/ui/instagram/InstagramAccountCard.tsx
new file mode 100644
index 0000000..866faca
--- /dev/null
+++ b/src/features/report/ui/instagram/InstagramAccountCard.tsx
@@ -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 (
+ 등록된 KPI가 없습니다.
;
+ }
+
+ return (
+
+
+
+
웹사이트 기술 진단
+
+
+
+
+ 기본 도메인: {website.primaryDomain}
+
+ {website.additionalDomains.length > 0 ? (
+
+ {website.additionalDomains.map((d) => (
+
+ {d.domain} — {d.purpose}
+
+ ))}
+
+ ) : null}
+
+ 주요 CTA: {website.mainCTA}
+
+
+
+
+
트래킹 픽셀 설치 현황
+
+ {website.trackingPixels.map((pixel) => (
+
+ ))}
+
+
+
+ {!website.snsLinksOnSite ? (
+
+
+
+
+ 홈페이지에 SNS 링크 없음
+
+
+ 웹사이트에서 소셜 미디어 채널로의 연결이 없습니다. 방문자가 SNS를 통해 브랜드와 연결할 수 없습니다.
+
+
+
+ ) : (
+
+
+
+ 홈페이지에 SNS 링크 연결됨
+
+
+ )}
+
+ );
+}
diff --git a/src/features/report/ui/overview/OverviewHeroBlobs.tsx b/src/features/report/ui/overview/OverviewHeroBlobs.tsx
new file mode 100644
index 0000000..059b40a
--- /dev/null
+++ b/src/features/report/ui/overview/OverviewHeroBlobs.tsx
@@ -0,0 +1,18 @@
+export function OverviewHeroBlobs() {
+ return (
+ <>
+ 등록된 로드맵이 없습니다.
;
+ }
+
+ return (
+ ("brand");
+
+ return (
+ <>
+
+ {TRANSFORMATION_TABS.map((tab) => {
+ const isActive = activeTab === tab.key;
+ return (
+
+ );
+ })}
+
+
+ {activeTab === "brand" ? (
+
+
브랜드 아이덴티티
+ {data.brandIdentity.map((item, i) => (
+
+ ))}
+
+ ) : null}
+
+ {activeTab === "content" ? (
+
+
콘텐츠 전략
+ {data.contentStrategy.map((item, i) => (
+
+ ))}
+
+ ) : null}
+
+ {activeTab === "platform" ? (
+
+ {data.platformStrategies.map((strategy, i) => (
+
+ ))}
+
+ ) : null}
+
+ {activeTab === "website" ? (
+
+
웹사이트 개선
+ {data.websiteImprovements.map((item, i) => (
+
+ ))}
+
+ ) : null}
+
+ {activeTab === "newChannel" ? (
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/src/features/report/ui/transformation/newChannelPriorityClass.ts b/src/features/report/ui/transformation/newChannelPriorityClass.ts
new file mode 100644
index 0000000..b9f2206
--- /dev/null
+++ b/src/features/report/ui/transformation/newChannelPriorityClass.ts
@@ -0,0 +1,11 @@
+/** P0·P1·한글 우선순위 등 → 배지용 Tailwind 클래스 */
+export function newChannelPriorityClass(priority: string): string {
+ const p = priority.trim().toLowerCase();
+ if (p === "p0" || p === "높음" || p === "high")
+ return "bg-[var(--color-status-critical-bg)] text-[var(--color-status-critical-text)] border-[var(--color-status-critical-border)]";
+ if (p === "p1" || p === "중간" || p === "medium")
+ return "bg-[var(--color-status-warning-bg)] text-[var(--color-status-warning-text)] border-[var(--color-status-warning-border)]";
+ if (p === "p2" || p === "낮음" || p === "low")
+ return "bg-[var(--color-status-good-bg)] text-[var(--color-status-good-text)] border-[var(--color-status-good-border)]";
+ return "bg-neutral-10 text-neutral-80 border-neutral-20";
+}
diff --git a/src/features/report/ui/transformation/platformStrategyIcon.tsx b/src/features/report/ui/transformation/platformStrategyIcon.tsx
new file mode 100644
index 0000000..d0ae8f5
--- /dev/null
+++ b/src/features/report/ui/transformation/platformStrategyIcon.tsx
@@ -0,0 +1,29 @@
+import type { ReactNode } 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";
+
+const size = 20;
+
+export function platformStrategyIconNode(iconKey: string): ReactNode {
+ const k = iconKey.toLowerCase();
+ switch (k) {
+ case "youtube":
+ return ;
+ case "instagram":
+ return ;
+ case "facebook":
+ return ;
+ case "naver":
+ return ;
+ case "tiktok":
+ return ;
+ case "website":
+ case "blog":
+ default:
+ return ;
+ }
+}
diff --git a/src/features/report/ui/transformation/transformationTabs.ts b/src/features/report/ui/transformation/transformationTabs.ts
new file mode 100644
index 0000000..4d7dd69
--- /dev/null
+++ b/src/features/report/ui/transformation/transformationTabs.ts
@@ -0,0 +1,9 @@
+export const TRANSFORMATION_TABS = [
+ { key: "brand", label: "Brand Identity", labelKr: "브랜드 아이덴티티" },
+ { key: "content", label: "Content Strategy", labelKr: "콘텐츠 전략" },
+ { key: "platform", label: "Platform Strategies", labelKr: "플랫폼 전략" },
+ { key: "website", label: "Website", labelKr: "웹사이트 개선" },
+ { key: "newChannel", label: "New Channels", labelKr: "신규 채널" },
+] as const;
+
+export type TransformationTabKey = (typeof TRANSFORMATION_TABS)[number]["key"];
diff --git a/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx b/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx
new file mode 100644
index 0000000..d01c710
--- /dev/null
+++ b/src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx
@@ -0,0 +1,47 @@
+import ChannelYoutubeIcon from "@/assets/icons/channel-youtube.svg?react";
+import ExternalLinkIcon from "@/assets/icons/external-link.svg?react";
+import type { YouTubeAudit } from "@/features/report/types/youtubeAudit";
+import { safeUrl } from "@/lib/safeUrl";
+
+export type YouTubeChannelInfoCardProps = {
+ data: YouTubeAudit;
+};
+
+export function YouTubeChannelInfoCard({ data }: YouTubeChannelInfoCardProps) {
+ return (
+
+
+
+
+
+
+
{data.channelName}
+
{data.handle}
+
+
+
{data.channelDescription}
+
+ 개설일: {data.channelCreatedDate}
+ 평균 영상 길이: {data.avgVideoLength}
+ 업로드 빈도: {data.uploadFrequency}
+
+
+ {data.linkedUrls.length > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx b/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx
new file mode 100644
index 0000000..b332eff
--- /dev/null
+++ b/src/features/report/ui/youtube/YouTubeMetricsGrid.tsx
@@ -0,0 +1,44 @@
+import EyeIcon from "@/assets/icons/eye.svg?react";
+import TrendingUpIcon from "@/assets/icons/trending-up.svg?react";
+import VideoIcon from "@/assets/icons/video.svg?react";
+import UsersIcon from "@/assets/icons/users.svg?react";
+import { MetricCard } from "@/components/card/MetricCard";
+import type { YouTubeAudit } from "@/features/report/types/youtubeAudit";
+import { formatCompactNumber } from "@/lib/formatNumber";
+
+export type YouTubeMetricsGridProps = {
+ data: YouTubeAudit;
+};
+
+export function YouTubeMetricsGrid({ data }: YouTubeMetricsGridProps) {
+ const growth = data.weeklyViewGrowth;
+ const trend = growth.percentage > 0 ? "up" : growth.percentage < 0 ? "down" : "neutral";
+
+ return (
+
+ }
+ subtext={data.subscriberRank}
+ />
+ }
+ />
+ }
+ />
+ }
+ subtext={`${growth.percentage > 0 ? "+" : ""}${growth.percentage}%`}
+ trend={trend}
+ />
+
+ );
+}
diff --git a/src/features/report/ui/youtube/YouTubeTopVideosBlock.tsx b/src/features/report/ui/youtube/YouTubeTopVideosBlock.tsx
new file mode 100644
index 0000000..e87090e
--- /dev/null
+++ b/src/features/report/ui/youtube/YouTubeTopVideosBlock.tsx
@@ -0,0 +1,34 @@
+import { TopVideoCard } from "@/components/card/TopVideoCard";
+import type { TopVideo } from "@/features/report/types/youtubeAudit";
+
+export type YouTubeTopVideosBlockProps = {
+ videos: TopVideo[];
+};
+
+export function YouTubeTopVideosBlock({ videos }: YouTubeTopVideosBlockProps) {
+ if (videos.length === 0) return null;
+
+ return (
+
+
+ 인기 영상 TOP {videos.length}
+
+
+ {videos.map((video, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/layouts/MainSubNavLayout.tsx b/src/layouts/MainSubNavLayout.tsx
new file mode 100644
index 0000000..1644e18
--- /dev/null
+++ b/src/layouts/MainSubNavLayout.tsx
@@ -0,0 +1,51 @@
+import { createContext, useContext, useMemo, useState } from "react";
+import { Outlet } from "react-router-dom";
+import { Footer } from "@/layouts/Footer";
+import { GNB } from "@/layouts/GNB";
+import { PageNavigator } from "@/layouts/PageNavigator";
+import { SubNav, type SubNavProps } from "@/layouts/SubNav";
+
+type MainSubNavContextValue = {
+ /** SubNav 설정 (null 전달 시 숨김 처리) */
+ setSubNav: (config: SubNavProps | null) => void;
+};
+
+const MainSubNavContext = createContext(null);
+
+/** 하위 페이지에서 SubNav를 제어하기 위한 Hook */
+export function useMainSubNav() {
+ const ctx = useContext(MainSubNavContext);
+ if (!ctx) {
+ throw new Error("useMainSubNav must be used within MainSubNavLayout");
+ }
+ return ctx;
+}
+
+/**
+ * GNB, Footer와 가변형 SubNav를 포함하는 공통 레이아웃
+ * * [사용법]
+ * 자식 페이지(Outlet)에서 useEffect를 통해:
+ * 1. 마운트 시: setSubNav({ items, ... }) 호출
+ * 2. 언마운트 시: setSubNav(null) 호출
+ */
+export default function MainSubNavLayout() {
+ const [subNav, setSubNav] = useState(null);
+
+ // 리렌더링 방지를 위한 컨텍스트 값 메모이제이션
+ const value = useMemo(() => ({ setSubNav }), []);
+
+ return (
+
+
+
+
+ {/* 하위 페이지에서 등록한 SubNav가 있을 때만 렌더링 */}
+ {subNav && }
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/layouts/PageNavigator.tsx b/src/layouts/PageNavigator.tsx
index 2bd929c..a28a217 100644
--- a/src/layouts/PageNavigator.tsx
+++ b/src/layouts/PageNavigator.tsx
@@ -2,21 +2,51 @@ import { useLocation, useNavigate } from "react-router-dom";
import ChevronLeftIcon from "@/assets/home/chevron-left.svg?react";
import ChevronRightIcon from "@/assets/home/chevron-right.svg?react";
-const PAGE_FLOW = [
- { path: "/", label: "홈" },
- { path: "/report", label: "마케팅 분석" },
- { path: "/plan", label: "콘텐츠 기획" },
- { path: "/studio", label: "콘텐츠 제작" },
- { path: "/channels", label: "채널 연결" },
- { path: "/distribute", label: "콘텐츠 배포" },
- { path: "/performance", label: "성과 관리" },
+/** 리포트 라우트가 `report/:id`일 때 점/플로우에서 이동할 기본 경로 */
+const DEFAULT_REPORT_NAV_PATH = "/report/demo";
+
+type FlowStep = {
+ id: string;
+ label: string;
+ /** 이전·다음·점 클릭 시 `navigate`에 넣을 경로 */
+ navigatePath: string;
+ isActive: (pathname: string) => boolean;
+};
+
+const PAGE_FLOW: FlowStep[] = [
+ { id: "home", label: "홈", navigatePath: "/", isActive: (p) => p === "/" },
+ {
+ id: "report",
+ label: "마케팅 분석",
+ navigatePath: DEFAULT_REPORT_NAV_PATH,
+ isActive: (p) => p === "/report" || p.startsWith("/report/"),
+ },
+ { id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
+ { id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
+ { id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
+ {
+ id: "distribute",
+ label: "콘텐츠 배포",
+ navigatePath: "/distribute",
+ isActive: (p) => p === "/distribute",
+ },
+ {
+ id: "performance",
+ label: "성과 관리",
+ navigatePath: "/performance",
+ isActive: (p) => p === "/performance",
+ },
];
+function flowIndexForPathname(pathname: string): number {
+ return PAGE_FLOW.findIndex((step) => step.isActive(pathname));
+}
+
export function PageNavigator() {
const location = useLocation();
const navigate = useNavigate();
- const currentIndex = PAGE_FLOW.findIndex((p) => p.path === location.pathname);
+ const currentIndex = flowIndexForPathname(location.pathname);
if (currentIndex === -1) return null;
const prev = currentIndex > 0 ? PAGE_FLOW[currentIndex - 1] : null;
@@ -29,7 +59,8 @@ export function PageNavigator() {
>
{/* 이전 페이지 */}