+
+
+
+ {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/SubNav.tsx b/src/layouts/SubNav.tsx
new file mode 100644
index 0000000..2076c9c
--- /dev/null
+++ b/src/layouts/SubNav.tsx
@@ -0,0 +1,124 @@
+import { useEffect, useRef, type ReactNode } from "react";
+import { NavLink } from "react-router-dom";
+
+export type SubNavItem = {
+ id: string;
+ label: ReactNode;
+ to?: string; // 라우트 이동 시 사용
+ targetId?: string; // 페이지 내 섹션 스크롤 시 사용
+ onClick?: (e: React.MouseEvent) => void;
+};
+
+export type SubNavProps = {
+ items: SubNavItem[];
+ activeId?: string;
+ isActive?: (item: SubNavItem) => boolean;
+ className?: string;
+ stickyClassName?: string;
+ trackClassName?: string;
+ scrollActiveIntoView?: boolean; // 활성 탭 자동 스크롤 여부
+};
+
+const defaultSticky = "sticky top-20 z-40 bg-white/80 backdrop-blur-lg border-b border-neutral-20";
+
+/** 일반 버튼/섹션 아이템의 활성 여부 판단 */
+function itemIsActive(item: SubNavItem, activeId: string | undefined, isActive: SubNavProps["isActive"]) {
+ if (isActive) return isActive(item);
+ return activeId !== undefined && item.id === activeId;
+}
+
+/** NavLink 아이템의 활성 여부 판단 (주입된 상태가 없으면 라우터 상태 우선) */
+function linkTabActive(
+ item: SubNavItem,
+ routeActive: boolean,
+ activeId: string | undefined,
+ isActive: SubNavProps["isActive"]
+) {
+ if (isActive !== undefined || activeId !== undefined) {
+ return itemIsActive(item, activeId, isActive);
+ }
+ return routeActive;
+}
+
+export function SubNav({
+ items,
+ activeId,
+ isActive,
+ className = "",
+ stickyClassName = defaultSticky,
+ trackClassName = "max-w-7xl mx-auto flex overflow-x-auto scrollbar-hide px-6",
+ scrollActiveIntoView = true,
+}: SubNavProps) {
+ const navRef = useRef(null);
+ const tabRefs = useRef