From 675cfcab8d301485d3f18a98217b9c121a791a3a Mon Sep 17 00:00:00 2001 From: minheon Date: Wed, 1 Apr 2026 10:52:50 +0900 Subject: [PATCH 1/2] =?UTF-8?q?[feat]=20report=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 5 + src/app/index.css | 2 +- src/assets/icons/alert-circle.svg | 4 + src/assets/icons/arrow-down.svg | 3 + src/assets/icons/arrow-up.svg | 3 + src/assets/icons/award.svg | 4 + src/assets/icons/channel-facebook.svg | 6 + src/assets/icons/channel-instagram.svg | 5 + src/assets/icons/channel-search.svg | 4 + src/assets/icons/channel-youtube.svg | 6 + src/assets/icons/external-link.svg | 3 + src/assets/icons/eye.svg | 10 + src/assets/icons/minus.svg | 3 + src/assets/icons/phone.svg | 9 + src/assets/icons/star.svg | 6 + src/assets/icons/trending-up.svg | 4 + src/assets/icons/users.svg | 9 + src/assets/icons/video.svg | 10 + src/assets/report/alert-triangle.svg | 10 + src/assets/report/arrow-up-right.svg | 9 + src/assets/report/calendar.svg | 4 + src/assets/report/check-circle.svg | 4 + src/assets/report/chevron-right.svg | 3 + src/assets/report/download.svg | 9 + src/assets/report/facebook-mark.svg | 6 + src/assets/report/globe.svg | 4 + src/assets/report/help-circle.svg | 10 + src/assets/report/image.svg | 5 + src/assets/report/link-2.svg | 9 + src/assets/report/map-pin.svg | 10 + src/assets/report/message-circle.svg | 9 + src/assets/report/x-circle.svg | 4 + src/components/badge/SeverityBadge.tsx | 45 ++++ src/components/brand/BrandConsistencyMap.tsx | 123 +++++++++++ src/components/card/ChannelScoreCard.tsx | 47 ++++ src/components/card/InfoStatCard.tsx | 28 +++ src/components/card/MetricCard.tsx | 39 ++++ src/components/card/PixelInstallCard.tsx | 38 ++++ src/components/card/TopVideoCard.tsx | 47 ++++ src/components/channel/OtherChannelRow.tsx | 74 +++++++ src/components/chip/TagChipList.tsx | 32 +++ src/components/compare/ComparisonRow.tsx | 19 ++ src/components/diagnosis/DiagnosisRow.tsx | 22 ++ src/components/index.ts | 16 ++ src/components/panel/ConsolidationCallout.tsx | 34 +++ src/components/panel/HighlightPanel.tsx | 22 ++ src/components/rating/ScoreRing.tsx | 87 ++++++++ src/components/rating/StarRatingDisplay.tsx | 38 ++++ src/components/section/PageSection.tsx | 58 +++++ .../report/constants/mock_channel_scores.ts | 10 + .../report/constants/mock_clinic_snapshot.ts | 36 +++ .../report/constants/mock_facebook_audit.ts | 130 +++++++++++ .../report/constants/mock_instagram_audit.ts | 44 ++++ src/features/report/constants/mock_kpi.ts | 14 ++ .../report/constants/mock_other_channels.ts | 35 +++ .../constants/mock_problem_diagnosis.ts | 23 ++ .../report/constants/mock_report_overview.ts | 11 + src/features/report/constants/mock_roadmap.ts | 45 ++++ .../report/constants/mock_transformation.ts | 83 +++++++ .../report/constants/mock_youtube_audit.ts | 66 ++++++ .../report/constants/report_sections.ts | 18 ++ src/features/report/hooks/useReportSubNav.ts | 52 +++++ src/features/report/types/channelScore.ts | 10 + src/features/report/types/clinicSnapshot.ts | 33 +++ src/features/report/types/diagnosis.ts | 8 + src/features/report/types/facebookAudit.ts | 30 +++ src/features/report/types/instagramAudit.ts | 22 ++ src/features/report/types/kpiDashboard.ts | 6 + src/features/report/types/reportOverview.ts | 9 + src/features/report/types/roadmap.ts | 11 + .../report/types/transformationProposal.ts | 32 +++ src/features/report/types/youtubeAudit.ts | 28 +++ .../report/ui/ReportChannelsSection.tsx | 16 ++ .../report/ui/ReportClinicSection.tsx | 20 ++ .../report/ui/ReportDiagnosisSection.tsx | 28 +++ .../report/ui/ReportFacebookSection.tsx | 52 +++++ .../report/ui/ReportInstagramSection.tsx | 35 +++ src/features/report/ui/ReportKpiSection.tsx | 18 ++ .../report/ui/ReportOtherChannelsSection.tsx | 22 ++ .../report/ui/ReportOverviewSection.tsx | 29 +++ .../report/ui/ReportRoadmapSection.tsx | 16 ++ .../report/ui/ReportTransformationSection.tsx | 20 ++ .../report/ui/ReportYouTubeSection.tsx | 43 ++++ .../report/ui/channels/ChannelScoreGrid.tsx | 33 +++ .../report/ui/channels/channelScoreIcons.tsx | 38 ++++ .../ui/clinic/ClinicCertificationsBlock.tsx | 15 ++ .../report/ui/clinic/ClinicInfoStatGrid.tsx | 25 +++ .../ui/clinic/ClinicLeadDoctorPanel.tsx | 18 ++ .../ui/clinic/clinicSnapshotStatRows.tsx | 60 +++++ .../ui/diagnosis/ProblemDiagnosisCard.tsx | 31 +++ .../report/ui/diagnosis/severityDotClass.ts | 13 ++ .../report/ui/facebook/FacebookPageCard.tsx | 206 ++++++++++++++++++ .../report/ui/facebook/langBadgeClass.ts | 7 + src/features/report/ui/index.tsx | 25 +++ .../ui/instagram/InstagramAccountCard.tsx | 103 +++++++++ .../report/ui/instagram/langBadgeClass.ts | 7 + .../report/ui/kpi/KpiMetricsTable.tsx | 52 +++++ .../ui/kpi/KpiTransformationCtaCard.tsx | 41 ++++ .../report/ui/kpi/kpiCurrentValueNegative.ts | 11 + .../ui/otherChannels/OtherChannelsList.tsx | 30 +++ .../otherChannels/WebsiteTechAuditBlock.tsx | 78 +++++++ .../report/ui/overview/OverviewHeroBlobs.tsx | 18 ++ .../report/ui/overview/OverviewHeroColumn.tsx | 39 ++++ .../report/ui/overview/OverviewMetaChips.tsx | 31 +++ .../report/ui/overview/OverviewScorePanel.tsx | 18 ++ .../ui/overview/overviewSectionStyles.ts | 5 + .../report/ui/roadmap/RoadmapMonthCard.tsx | 34 +++ .../report/ui/roadmap/RoadmapMonthsGrid.tsx | 20 ++ .../report/ui/roadmap/RoadmapTaskItem.tsx | 37 ++++ .../NewChannelProposalsTable.tsx | 49 +++++ .../transformation/PlatformStrategyCard.tsx | 46 ++++ .../TransformationTabbedView.tsx | 94 ++++++++ .../transformation/newChannelPriorityClass.ts | 11 + .../transformation/platformStrategyIcon.tsx | 29 +++ .../ui/transformation/transformationTabs.ts | 9 + .../ui/youtube/YouTubeChannelInfoCard.tsx | 47 ++++ .../report/ui/youtube/YouTubeMetricsGrid.tsx | 44 ++++ .../ui/youtube/YouTubeTopVideosBlock.tsx | 34 +++ src/layouts/MainSubNavLayout.tsx | 51 +++++ src/layouts/SubNav.tsx | 124 +++++++++++ src/lib/formatNumber.ts | 6 + src/lib/safeUrl.ts | 7 + src/pages/Report.tsx | 40 ++++ src/types/brandConsistency.ts | 13 ++ src/types/otherChannels.ts | 27 +++ src/types/severity.ts | 1 + 126 files changed, 3642 insertions(+), 1 deletion(-) create mode 100644 src/assets/icons/alert-circle.svg create mode 100644 src/assets/icons/arrow-down.svg create mode 100644 src/assets/icons/arrow-up.svg create mode 100644 src/assets/icons/award.svg create mode 100644 src/assets/icons/channel-facebook.svg create mode 100644 src/assets/icons/channel-instagram.svg create mode 100644 src/assets/icons/channel-search.svg create mode 100644 src/assets/icons/channel-youtube.svg create mode 100644 src/assets/icons/external-link.svg create mode 100644 src/assets/icons/eye.svg create mode 100644 src/assets/icons/minus.svg create mode 100644 src/assets/icons/phone.svg create mode 100644 src/assets/icons/star.svg create mode 100644 src/assets/icons/trending-up.svg create mode 100644 src/assets/icons/users.svg create mode 100644 src/assets/icons/video.svg create mode 100644 src/assets/report/alert-triangle.svg create mode 100644 src/assets/report/arrow-up-right.svg create mode 100644 src/assets/report/calendar.svg create mode 100644 src/assets/report/check-circle.svg create mode 100644 src/assets/report/chevron-right.svg create mode 100644 src/assets/report/download.svg create mode 100644 src/assets/report/facebook-mark.svg create mode 100644 src/assets/report/globe.svg create mode 100644 src/assets/report/help-circle.svg create mode 100644 src/assets/report/image.svg create mode 100644 src/assets/report/link-2.svg create mode 100644 src/assets/report/map-pin.svg create mode 100644 src/assets/report/message-circle.svg create mode 100644 src/assets/report/x-circle.svg create mode 100644 src/components/badge/SeverityBadge.tsx create mode 100644 src/components/brand/BrandConsistencyMap.tsx create mode 100644 src/components/card/ChannelScoreCard.tsx create mode 100644 src/components/card/InfoStatCard.tsx create mode 100644 src/components/card/MetricCard.tsx create mode 100644 src/components/card/PixelInstallCard.tsx create mode 100644 src/components/card/TopVideoCard.tsx create mode 100644 src/components/channel/OtherChannelRow.tsx create mode 100644 src/components/chip/TagChipList.tsx create mode 100644 src/components/compare/ComparisonRow.tsx create mode 100644 src/components/diagnosis/DiagnosisRow.tsx create mode 100644 src/components/index.ts create mode 100644 src/components/panel/ConsolidationCallout.tsx create mode 100644 src/components/panel/HighlightPanel.tsx create mode 100644 src/components/rating/ScoreRing.tsx create mode 100644 src/components/rating/StarRatingDisplay.tsx create mode 100644 src/components/section/PageSection.tsx create mode 100644 src/features/report/constants/mock_channel_scores.ts create mode 100644 src/features/report/constants/mock_clinic_snapshot.ts create mode 100644 src/features/report/constants/mock_facebook_audit.ts create mode 100644 src/features/report/constants/mock_instagram_audit.ts create mode 100644 src/features/report/constants/mock_kpi.ts create mode 100644 src/features/report/constants/mock_other_channels.ts create mode 100644 src/features/report/constants/mock_problem_diagnosis.ts create mode 100644 src/features/report/constants/mock_report_overview.ts create mode 100644 src/features/report/constants/mock_roadmap.ts create mode 100644 src/features/report/constants/mock_transformation.ts create mode 100644 src/features/report/constants/mock_youtube_audit.ts create mode 100644 src/features/report/constants/report_sections.ts create mode 100644 src/features/report/hooks/useReportSubNav.ts create mode 100644 src/features/report/types/channelScore.ts create mode 100644 src/features/report/types/clinicSnapshot.ts create mode 100644 src/features/report/types/diagnosis.ts create mode 100644 src/features/report/types/facebookAudit.ts create mode 100644 src/features/report/types/instagramAudit.ts create mode 100644 src/features/report/types/kpiDashboard.ts create mode 100644 src/features/report/types/reportOverview.ts create mode 100644 src/features/report/types/roadmap.ts create mode 100644 src/features/report/types/transformationProposal.ts create mode 100644 src/features/report/types/youtubeAudit.ts create mode 100644 src/features/report/ui/ReportChannelsSection.tsx create mode 100644 src/features/report/ui/ReportClinicSection.tsx create mode 100644 src/features/report/ui/ReportDiagnosisSection.tsx create mode 100644 src/features/report/ui/ReportFacebookSection.tsx create mode 100644 src/features/report/ui/ReportInstagramSection.tsx create mode 100644 src/features/report/ui/ReportKpiSection.tsx create mode 100644 src/features/report/ui/ReportOtherChannelsSection.tsx create mode 100644 src/features/report/ui/ReportOverviewSection.tsx create mode 100644 src/features/report/ui/ReportRoadmapSection.tsx create mode 100644 src/features/report/ui/ReportTransformationSection.tsx create mode 100644 src/features/report/ui/ReportYouTubeSection.tsx create mode 100644 src/features/report/ui/channels/ChannelScoreGrid.tsx create mode 100644 src/features/report/ui/channels/channelScoreIcons.tsx create mode 100644 src/features/report/ui/clinic/ClinicCertificationsBlock.tsx create mode 100644 src/features/report/ui/clinic/ClinicInfoStatGrid.tsx create mode 100644 src/features/report/ui/clinic/ClinicLeadDoctorPanel.tsx create mode 100644 src/features/report/ui/clinic/clinicSnapshotStatRows.tsx create mode 100644 src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx create mode 100644 src/features/report/ui/diagnosis/severityDotClass.ts create mode 100644 src/features/report/ui/facebook/FacebookPageCard.tsx create mode 100644 src/features/report/ui/facebook/langBadgeClass.ts create mode 100644 src/features/report/ui/index.tsx create mode 100644 src/features/report/ui/instagram/InstagramAccountCard.tsx create mode 100644 src/features/report/ui/instagram/langBadgeClass.ts create mode 100644 src/features/report/ui/kpi/KpiMetricsTable.tsx create mode 100644 src/features/report/ui/kpi/KpiTransformationCtaCard.tsx create mode 100644 src/features/report/ui/kpi/kpiCurrentValueNegative.ts create mode 100644 src/features/report/ui/otherChannels/OtherChannelsList.tsx create mode 100644 src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx create mode 100644 src/features/report/ui/overview/OverviewHeroBlobs.tsx create mode 100644 src/features/report/ui/overview/OverviewHeroColumn.tsx create mode 100644 src/features/report/ui/overview/OverviewMetaChips.tsx create mode 100644 src/features/report/ui/overview/OverviewScorePanel.tsx create mode 100644 src/features/report/ui/overview/overviewSectionStyles.ts create mode 100644 src/features/report/ui/roadmap/RoadmapMonthCard.tsx create mode 100644 src/features/report/ui/roadmap/RoadmapMonthsGrid.tsx create mode 100644 src/features/report/ui/roadmap/RoadmapTaskItem.tsx create mode 100644 src/features/report/ui/transformation/NewChannelProposalsTable.tsx create mode 100644 src/features/report/ui/transformation/PlatformStrategyCard.tsx create mode 100644 src/features/report/ui/transformation/TransformationTabbedView.tsx create mode 100644 src/features/report/ui/transformation/newChannelPriorityClass.ts create mode 100644 src/features/report/ui/transformation/platformStrategyIcon.tsx create mode 100644 src/features/report/ui/transformation/transformationTabs.ts create mode 100644 src/features/report/ui/youtube/YouTubeChannelInfoCard.tsx create mode 100644 src/features/report/ui/youtube/YouTubeMetricsGrid.tsx create mode 100644 src/features/report/ui/youtube/YouTubeTopVideosBlock.tsx create mode 100644 src/layouts/MainSubNavLayout.tsx create mode 100644 src/layouts/SubNav.tsx create mode 100644 src/lib/formatNumber.ts create mode 100644 src/lib/safeUrl.ts create mode 100644 src/pages/Report.tsx create mode 100644 src/types/brandConsistency.ts create mode 100644 src/types/otherChannels.ts create mode 100644 src/types/severity.ts diff --git a/src/app/App.tsx b/src/app/App.tsx index 0c9efeb..b9ff8e7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,9 +1,11 @@ import { Routes, Route } from "react-router-dom"; // layouts import MainLayout from "@/layouts/MainLayout"; +import MainSubNavLayout from "@/layouts/MainSubNavLayout"; // pages import { Home } from "@/pages/Home"; +import { ReportPage } from "@/pages/Report"; function App() { @@ -12,6 +14,9 @@ function App() { }> } /> + }> + } /> + ) } diff --git a/src/app/index.css b/src/app/index.css index 1158c04..9aa6294 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -150,7 +150,7 @@ /* 브랜드 그라디언트 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 ────────────────────────────────────────────── */ diff --git a/src/assets/icons/alert-circle.svg b/src/assets/icons/alert-circle.svg new file mode 100644 index 0000000..4609d64 --- /dev/null +++ b/src/assets/icons/alert-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/arrow-down.svg b/src/assets/icons/arrow-down.svg new file mode 100644 index 0000000..43b5014 --- /dev/null +++ b/src/assets/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/arrow-up.svg b/src/assets/icons/arrow-up.svg new file mode 100644 index 0000000..104ac37 --- /dev/null +++ b/src/assets/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/award.svg b/src/assets/icons/award.svg new file mode 100644 index 0000000..db851c7 --- /dev/null +++ b/src/assets/icons/award.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/channel-facebook.svg b/src/assets/icons/channel-facebook.svg new file mode 100644 index 0000000..6fe4553 --- /dev/null +++ b/src/assets/icons/channel-facebook.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/assets/icons/channel-instagram.svg b/src/assets/icons/channel-instagram.svg new file mode 100644 index 0000000..c939fdf --- /dev/null +++ b/src/assets/icons/channel-instagram.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/icons/channel-search.svg b/src/assets/icons/channel-search.svg new file mode 100644 index 0000000..ce032e0 --- /dev/null +++ b/src/assets/icons/channel-search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/channel-youtube.svg b/src/assets/icons/channel-youtube.svg new file mode 100644 index 0000000..c655ff1 --- /dev/null +++ b/src/assets/icons/channel-youtube.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/assets/icons/external-link.svg b/src/assets/icons/external-link.svg new file mode 100644 index 0000000..c750cc1 --- /dev/null +++ b/src/assets/icons/external-link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/eye.svg b/src/assets/icons/eye.svg new file mode 100644 index 0000000..6a8cd2c --- /dev/null +++ b/src/assets/icons/eye.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/assets/icons/minus.svg b/src/assets/icons/minus.svg new file mode 100644 index 0000000..863ae7a --- /dev/null +++ b/src/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/phone.svg b/src/assets/icons/phone.svg new file mode 100644 index 0000000..c9ad863 --- /dev/null +++ b/src/assets/icons/phone.svg @@ -0,0 +1,9 @@ + + + diff --git a/src/assets/icons/star.svg b/src/assets/icons/star.svg new file mode 100644 index 0000000..0c24d43 --- /dev/null +++ b/src/assets/icons/star.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/assets/icons/trending-up.svg b/src/assets/icons/trending-up.svg new file mode 100644 index 0000000..2dfabc2 --- /dev/null +++ b/src/assets/icons/trending-up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/users.svg b/src/assets/icons/users.svg new file mode 100644 index 0000000..8220c7b --- /dev/null +++ b/src/assets/icons/users.svg @@ -0,0 +1,9 @@ + + + diff --git a/src/assets/icons/video.svg b/src/assets/icons/video.svg new file mode 100644 index 0000000..6c90634 --- /dev/null +++ b/src/assets/icons/video.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/assets/report/alert-triangle.svg b/src/assets/report/alert-triangle.svg new file mode 100644 index 0000000..e78dd80 --- /dev/null +++ b/src/assets/report/alert-triangle.svg @@ -0,0 +1,10 @@ + diff --git a/src/assets/report/arrow-up-right.svg b/src/assets/report/arrow-up-right.svg new file mode 100644 index 0000000..9c73f18 --- /dev/null +++ b/src/assets/report/arrow-up-right.svg @@ -0,0 +1,9 @@ + diff --git a/src/assets/report/calendar.svg b/src/assets/report/calendar.svg new file mode 100644 index 0000000..bd50505 --- /dev/null +++ b/src/assets/report/calendar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/report/check-circle.svg b/src/assets/report/check-circle.svg new file mode 100644 index 0000000..3822028 --- /dev/null +++ b/src/assets/report/check-circle.svg @@ -0,0 +1,4 @@ + diff --git a/src/assets/report/chevron-right.svg b/src/assets/report/chevron-right.svg new file mode 100644 index 0000000..25fc834 --- /dev/null +++ b/src/assets/report/chevron-right.svg @@ -0,0 +1,3 @@ + diff --git a/src/assets/report/download.svg b/src/assets/report/download.svg new file mode 100644 index 0000000..35ada3a --- /dev/null +++ b/src/assets/report/download.svg @@ -0,0 +1,9 @@ + diff --git a/src/assets/report/facebook-mark.svg b/src/assets/report/facebook-mark.svg new file mode 100644 index 0000000..d3381ca --- /dev/null +++ b/src/assets/report/facebook-mark.svg @@ -0,0 +1,6 @@ + diff --git a/src/assets/report/globe.svg b/src/assets/report/globe.svg new file mode 100644 index 0000000..f80921c --- /dev/null +++ b/src/assets/report/globe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/report/help-circle.svg b/src/assets/report/help-circle.svg new file mode 100644 index 0000000..882880e --- /dev/null +++ b/src/assets/report/help-circle.svg @@ -0,0 +1,10 @@ + diff --git a/src/assets/report/image.svg b/src/assets/report/image.svg new file mode 100644 index 0000000..e137113 --- /dev/null +++ b/src/assets/report/image.svg @@ -0,0 +1,5 @@ + diff --git a/src/assets/report/link-2.svg b/src/assets/report/link-2.svg new file mode 100644 index 0000000..40e5ce3 --- /dev/null +++ b/src/assets/report/link-2.svg @@ -0,0 +1,9 @@ + diff --git a/src/assets/report/map-pin.svg b/src/assets/report/map-pin.svg new file mode 100644 index 0000000..ffc76fd --- /dev/null +++ b/src/assets/report/map-pin.svg @@ -0,0 +1,10 @@ + + + + diff --git a/src/assets/report/message-circle.svg b/src/assets/report/message-circle.svg new file mode 100644 index 0000000..57551c0 --- /dev/null +++ b/src/assets/report/message-circle.svg @@ -0,0 +1,9 @@ + diff --git a/src/assets/report/x-circle.svg b/src/assets/report/x-circle.svg new file mode 100644 index 0000000..0331dff --- /dev/null +++ b/src/assets/report/x-circle.svg @@ -0,0 +1,4 @@ + diff --git a/src/components/badge/SeverityBadge.tsx b/src/components/badge/SeverityBadge.tsx new file mode 100644 index 0000000..2426900 --- /dev/null +++ b/src/components/badge/SeverityBadge.tsx @@ -0,0 +1,45 @@ +import type { Severity } from "@/types/severity"; + +export type SeverityBadgeProps = { + severity: Severity; + label?: string; +}; + +const config: Record = { + 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 ( + + {label ?? defaultLabel} + + ); +} diff --git a/src/components/brand/BrandConsistencyMap.tsx b/src/components/brand/BrandConsistencyMap.tsx new file mode 100644 index 0000000..3e9fdde --- /dev/null +++ b/src/components/brand/BrandConsistencyMap.tsx @@ -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(0); + + if (inconsistencies.length === 0) return null; + + return ( +
+

Brand Consistency Map

+

전 채널 브랜드 일관성 분석

+ +
+ {inconsistencies.map((item, i) => { + const wrongCount = item.values.filter((v) => !v.isCorrect).length; + const isOpen = expanded === i; + + return ( +
+ + + {isOpen ? ( +
+
+ {item.values.map((v) => ( +
+ + {v.channel} + + + {v.value} + + + {v.isCorrect ? ( + + ) : ( + + )} + +
+ ))} +
+ +
+

+ + Impact +

+

{item.impact}

+
+ +
+

+ + Recommendation +

+

{item.recommendation}

+
+
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/card/ChannelScoreCard.tsx b/src/components/card/ChannelScoreCard.tsx new file mode 100644 index 0000000..24fb864 --- /dev/null +++ b/src/components/card/ChannelScoreCard.tsx @@ -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 ( +
+
+ {icon} +
+

{channel}

+ +

{headline}

+ +
+ ); +} diff --git a/src/components/card/InfoStatCard.tsx b/src/components/card/InfoStatCard.tsx new file mode 100644 index 0000000..5d0427a --- /dev/null +++ b/src/components/card/InfoStatCard.tsx @@ -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 ( +
+
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+
+ ); +} diff --git a/src/components/card/MetricCard.tsx b/src/components/card/MetricCard.tsx new file mode 100644 index 0000000..9e00a5a --- /dev/null +++ b/src/components/card/MetricCard.tsx @@ -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 ( +
+ {icon ?
{icon}
: null} +

{label}

+
+ {value} + {trend && TrendGlyph ? ( + + + + ) : null} +
+ {subtext ?

{subtext}

: null} +
+ ); +} diff --git a/src/components/card/PixelInstallCard.tsx b/src/components/card/PixelInstallCard.tsx new file mode 100644 index 0000000..fa047cf --- /dev/null +++ b/src/components/card/PixelInstallCard.tsx @@ -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 ( +
+ {installed ? ( + + ) : ( + + )} +
+

+ {name} +

+ {details ? ( +

{details}

+ ) : null} +
+
+ ); +} diff --git a/src/components/card/TopVideoCard.tsx b/src/components/card/TopVideoCard.tsx new file mode 100644 index 0000000..790624f --- /dev/null +++ b/src/components/card/TopVideoCard.tsx @@ -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 ( +
+
+ {type} + {duration ? {duration} : null} + {uploadedAgo} +
+

{title}

+
+ + {formatCompactNumber(views)} + views +
+
+ ); +} diff --git a/src/components/channel/OtherChannelRow.tsx b/src/components/channel/OtherChannelRow.tsx new file mode 100644 index 0000000..dbb2c32 --- /dev/null +++ b/src/components/channel/OtherChannelRow.tsx @@ -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 ( +
+ +
+

{name}

+

{details}

+
+ {meta.label} + {url ? ( + + + + ) : null} +
+ ); +} diff --git a/src/components/chip/TagChipList.tsx b/src/components/chip/TagChipList.tsx new file mode 100644 index 0000000..8b0eb86 --- /dev/null +++ b/src/components/chip/TagChipList.tsx @@ -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 ( +
+ {title ?

{title}

: null} +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ ); +} diff --git a/src/components/compare/ComparisonRow.tsx b/src/components/compare/ComparisonRow.tsx new file mode 100644 index 0000000..73325f8 --- /dev/null +++ b/src/components/compare/ComparisonRow.tsx @@ -0,0 +1,19 @@ +export type ComparisonRowProps = { + area: string; + asIs: string; + toBe: string; +}; + +export function ComparisonRow({ area, asIs, toBe }: ComparisonRowProps) { + return ( +
+ {area} +
+ {asIs} +
+
+ {toBe} +
+
+ ); +} diff --git a/src/components/diagnosis/DiagnosisRow.tsx b/src/components/diagnosis/DiagnosisRow.tsx new file mode 100644 index 0000000..381359f --- /dev/null +++ b/src/components/diagnosis/DiagnosisRow.tsx @@ -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 ( +
+
+ {category} +

{detail}

+
+ +
+
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..c3a2d13 --- /dev/null +++ b/src/components/index.ts @@ -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"; diff --git a/src/components/panel/ConsolidationCallout.tsx b/src/components/panel/ConsolidationCallout.tsx new file mode 100644 index 0000000..85dee4c --- /dev/null +++ b/src/components/panel/ConsolidationCallout.tsx @@ -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 ( +
+
+ {icon ? ( +
+ {icon} +
+ ) : null} +
+

{title}

+
{children}
+
+
+
+ ); +} diff --git a/src/components/panel/HighlightPanel.tsx b/src/components/panel/HighlightPanel.tsx new file mode 100644 index 0000000..8db4158 --- /dev/null +++ b/src/components/panel/HighlightPanel.tsx @@ -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 ( +
+
+ {icon ? {icon} : null} +

{title}

+
+ {children} +
+ ); +} diff --git a/src/components/rating/ScoreRing.tsx b/src/components/rating/ScoreRing.tsx new file mode 100644 index 0000000..fc6144c --- /dev/null +++ b/src/components/rating/ScoreRing.tsx @@ -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 ( +
+
+ + + + +
+ {score} +
+
+ {label ? {label} : null} +
+ ); +} diff --git a/src/components/rating/StarRatingDisplay.tsx b/src/components/rating/StarRatingDisplay.tsx new file mode 100644 index 0000000..5faade3 --- /dev/null +++ b/src/components/rating/StarRatingDisplay.tsx @@ -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 ( +
+
+ {Array.from({ length: maxStars }, (_, i) => ( + + ))} + {rating} +
+ {reviewCount != null ? ( + 리뷰 {formatCount(reviewCount)}건 + ) : null} +
+ ); +} diff --git a/src/components/section/PageSection.tsx b/src/components/section/PageSection.tsx new file mode 100644 index 0000000..84dd7c8 --- /dev/null +++ b/src/components/section/PageSection.tsx @@ -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 ( +
+ {dark ? ( +
+ ) : null} +
+
+

+ {title} +

+ {subtitle ? ( +

+ {subtitle} +

+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/src/features/report/constants/mock_channel_scores.ts b/src/features/report/constants/mock_channel_scores.ts new file mode 100644 index 0000000..542e3a6 --- /dev/null +++ b/src/features/report/constants/mock_channel_scores.ts @@ -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 연결 없음, 트래킹만 존재" }, +]; diff --git a/src/features/report/constants/mock_clinic_snapshot.ts b/src/features/report/constants/mock_clinic_snapshot.ts new file mode 100644 index 0000000..8ed5126 --- /dev/null +++ b/src/features/report/constants/mock_clinic_snapshot.ts @@ -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", +}; diff --git a/src/features/report/constants/mock_facebook_audit.ts b/src/features/report/constants/mock_facebook_audit.ts new file mode 100644 index 0000000..5d1a251 --- /dev/null +++ b/src/features/report/constants/mock_facebook_audit.ts @@ -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 기반 리타겟 광고 전용 채널로 운영하는 것이 효율적입니다.", +}; diff --git a/src/features/report/constants/mock_instagram_audit.ts b/src/features/report/constants/mock_instagram_audit.ts new file mode 100644 index 0000000..ebd3659 --- /dev/null +++ b/src/features/report/constants/mock_instagram_audit.ts @@ -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" }, + ], +}; diff --git a/src/features/report/constants/mock_kpi.ts b/src/features/report/constants/mock_kpi.ts new file mode 100644 index 0000000..be5113c --- /dev/null +++ b/src/features/report/constants/mock_kpi.ts @@ -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건" }, +]; diff --git a/src/features/report/constants/mock_other_channels.ts b/src/features/report/constants/mock_other_channels.ts new file mode 100644 index 0000000..d2b29fb --- /dev/null +++ b/src/features/report/constants/mock_other_channels.ts @@ -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: "전화 + 카카오톡 상담", + }, +}; diff --git a/src/features/report/constants/mock_problem_diagnosis.ts b/src/features/report/constants/mock_problem_diagnosis.ts new file mode 100644 index 0000000..d4a8f17 --- /dev/null +++ b/src/features/report/constants/mock_problem_diagnosis.ts @@ -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", + }, +]; diff --git a/src/features/report/constants/mock_report_overview.ts b/src/features/report/constants/mock_report_overview.ts new file mode 100644 index 0000000..2f3ae37 --- /dev/null +++ b/src/features/report/constants/mock_report_overview.ts @@ -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 (논현동)", +}; diff --git a/src/features/report/constants/mock_roadmap.ts b/src/features/report/constants/mock_roadmap.ts new file mode 100644 index 0000000..2ffd716 --- /dev/null +++ b/src/features/report/constants/mock_roadmap.ts @@ -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 }, + ], + }, +]; diff --git a/src/features/report/constants/mock_transformation.ts b/src/features/report/constants/mock_transformation.ts new file mode 100644 index 0000000..b4a34f0 --- /dev/null +++ b/src/features/report/constants/mock_transformation.ts @@ -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: "지역 검색 노출 필수" }, + ], +}; diff --git a/src/features/report/constants/mock_youtube_audit.ts b/src/features/report/constants/mock_youtube_audit.ts new file mode 100644 index 0000000..9b7eff5 --- /dev/null +++ b/src/features/report/constants/mock_youtube_audit.ts @@ -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" }, + ], +}; diff --git a/src/features/report/constants/report_sections.ts b/src/features/report/constants/report_sections.ts new file mode 100644 index 0000000..c1a8e42 --- /dev/null +++ b/src/features/report/constants/report_sections.ts @@ -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"]; diff --git a/src/features/report/hooks/useReportSubNav.ts b/src/features/report/hooks/useReportSubNav.ts new file mode 100644 index 0000000..950daaa --- /dev/null +++ b/src/features/report/hooks/useReportSubNav.ts @@ -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(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]); +} diff --git a/src/features/report/types/channelScore.ts b/src/features/report/types/channelScore.ts new file mode 100644 index 0000000..fae2696 --- /dev/null +++ b/src/features/report/types/channelScore.ts @@ -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; +}; diff --git a/src/features/report/types/clinicSnapshot.ts b/src/features/report/types/clinicSnapshot.ts new file mode 100644 index 0000000..f3abc3b --- /dev/null +++ b/src/features/report/types/clinicSnapshot.ts @@ -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; + }; +}; diff --git a/src/features/report/types/diagnosis.ts b/src/features/report/types/diagnosis.ts new file mode 100644 index 0000000..7ed2c53 --- /dev/null +++ b/src/features/report/types/diagnosis.ts @@ -0,0 +1,8 @@ +import type { Severity } from "@/types/severity"; + +export type DiagnosisItem = { + category: string; + detail: string; + severity: Severity; + evidenceIds?: string[]; +}; diff --git a/src/features/report/types/facebookAudit.ts b/src/features/report/types/facebookAudit.ts new file mode 100644 index 0000000..467e4b8 --- /dev/null +++ b/src/features/report/types/facebookAudit.ts @@ -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; +}; diff --git a/src/features/report/types/instagramAudit.ts b/src/features/report/types/instagramAudit.ts new file mode 100644 index 0000000..12ed633 --- /dev/null +++ b/src/features/report/types/instagramAudit.ts @@ -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[]; +}; diff --git a/src/features/report/types/kpiDashboard.ts b/src/features/report/types/kpiDashboard.ts new file mode 100644 index 0000000..fdaac08 --- /dev/null +++ b/src/features/report/types/kpiDashboard.ts @@ -0,0 +1,6 @@ +export type KpiMetric = { + metric: string; + current: string; + target3Month: string; + target12Month: string; +}; diff --git a/src/features/report/types/reportOverview.ts b/src/features/report/types/reportOverview.ts new file mode 100644 index 0000000..b50428d --- /dev/null +++ b/src/features/report/types/reportOverview.ts @@ -0,0 +1,9 @@ +export type ReportOverviewData = { + clinicName: string; + clinicNameEn: string; + overallScore: number; + date: string; + targetUrl: string; + location: string; + logoImage?: string; +}; diff --git a/src/features/report/types/roadmap.ts b/src/features/report/types/roadmap.ts new file mode 100644 index 0000000..e85826d --- /dev/null +++ b/src/features/report/types/roadmap.ts @@ -0,0 +1,11 @@ +export type RoadmapTask = { + task: string; + completed: boolean; +}; + +export type RoadmapMonth = { + month: number; + title: string; + subtitle: string; + tasks: RoadmapTask[]; +}; diff --git a/src/features/report/types/transformationProposal.ts b/src/features/report/types/transformationProposal.ts new file mode 100644 index 0000000..8b10696 --- /dev/null +++ b/src/features/report/types/transformationProposal.ts @@ -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[]; +}; diff --git a/src/features/report/types/youtubeAudit.ts b/src/features/report/types/youtubeAudit.ts new file mode 100644 index 0000000..ebae79a --- /dev/null +++ b/src/features/report/types/youtubeAudit.ts @@ -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[]; +}; diff --git a/src/features/report/ui/ReportChannelsSection.tsx b/src/features/report/ui/ReportChannelsSection.tsx new file mode 100644 index 0000000..5a1392f --- /dev/null +++ b/src/features/report/ui/ReportChannelsSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/report/ui/ReportClinicSection.tsx b/src/features/report/ui/ReportClinicSection.tsx new file mode 100644 index 0000000..f44ff2f --- /dev/null +++ b/src/features/report/ui/ReportClinicSection.tsx @@ -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 ( + + + + + + ); +} diff --git a/src/features/report/ui/ReportDiagnosisSection.tsx b/src/features/report/ui/ReportDiagnosisSection.tsx new file mode 100644 index 0000000..c463579 --- /dev/null +++ b/src/features/report/ui/ReportDiagnosisSection.tsx @@ -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 ( + +

등록된 핵심 진단 항목이 없습니다.

+
+ ); + } + + return ( + +
+ {items.map((item, i) => ( + + ))} +
+
+ ); +} diff --git a/src/features/report/ui/ReportFacebookSection.tsx b/src/features/report/ui/ReportFacebookSection.tsx new file mode 100644 index 0000000..099ad9f --- /dev/null +++ b/src/features/report/ui/ReportFacebookSection.tsx @@ -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 ( + +
+ {data.pages.map((page, i) => ( + + ))} +
+ + {data.brandInconsistencies.length > 0 ? ( + + ) : null} + + {data.diagnosis.length > 0 ? ( +
+

진단 결과

+

Facebook 채널 문제점

+ {data.diagnosis.map((item, i) => ( + + ))} +
+ ) : null} + + {data.consolidationRecommendation ? ( + } + > + {data.consolidationRecommendation} + + ) : null} +
+ ); +} diff --git a/src/features/report/ui/ReportInstagramSection.tsx b/src/features/report/ui/ReportInstagramSection.tsx new file mode 100644 index 0000000..f88808f --- /dev/null +++ b/src/features/report/ui/ReportInstagramSection.tsx @@ -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 ( + +
+ {data.accounts.map((account, i) => ( + + ))} +
+ + {data.diagnosis.length > 0 ? ( +
+

진단 결과

+ {data.diagnosis.map((item, i) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/features/report/ui/ReportKpiSection.tsx b/src/features/report/ui/ReportKpiSection.tsx new file mode 100644 index 0000000..a0de3b3 --- /dev/null +++ b/src/features/report/ui/ReportKpiSection.tsx @@ -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 ( + + + + + ); +} diff --git a/src/features/report/ui/ReportOtherChannelsSection.tsx b/src/features/report/ui/ReportOtherChannelsSection.tsx new file mode 100644 index 0000000..8b3c0c4 --- /dev/null +++ b/src/features/report/ui/ReportOtherChannelsSection.tsx @@ -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 ( + + + + + ); +} diff --git a/src/features/report/ui/ReportOverviewSection.tsx b/src/features/report/ui/ReportOverviewSection.tsx new file mode 100644 index 0000000..a3ecc3d --- /dev/null +++ b/src/features/report/ui/ReportOverviewSection.tsx @@ -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 ( + + ); +} diff --git a/src/features/report/ui/ReportRoadmapSection.tsx b/src/features/report/ui/ReportRoadmapSection.tsx new file mode 100644 index 0000000..c376c98 --- /dev/null +++ b/src/features/report/ui/ReportRoadmapSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/report/ui/ReportTransformationSection.tsx b/src/features/report/ui/ReportTransformationSection.tsx new file mode 100644 index 0000000..0c88b83 --- /dev/null +++ b/src/features/report/ui/ReportTransformationSection.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/report/ui/ReportYouTubeSection.tsx b/src/features/report/ui/ReportYouTubeSection.tsx new file mode 100644 index 0000000..4c64914 --- /dev/null +++ b/src/features/report/ui/ReportYouTubeSection.tsx @@ -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 ( + + + + + {data.playlists.length > 0 ? ( +
+ +
+ ) : null} + + + + {data.diagnosis.length > 0 ? ( +
+

진단 결과

+ {data.diagnosis.map((item, i) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/features/report/ui/channels/ChannelScoreGrid.tsx b/src/features/report/ui/channels/ChannelScoreGrid.tsx new file mode 100644 index 0000000..b6b92a8 --- /dev/null +++ b/src/features/report/ui/channels/ChannelScoreGrid.tsx @@ -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 ( +
+ {channels.map((ch, i) => { + const iconKey = ch.icon?.toLowerCase() ?? ""; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/report/ui/channels/channelScoreIcons.tsx b/src/features/report/ui/channels/channelScoreIcons.tsx new file mode 100644 index 0000000..6985ba3 --- /dev/null +++ b/src/features/report/ui/channels/channelScoreIcons.tsx @@ -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>; + +const CHANNEL_ICONS: Record = { + youtube: ChannelYoutubeIcon, + instagram: ChannelInstagramIcon, + facebook: ChannelFacebookIcon, + star: StarIcon, + globe: GlobeIcon, + search: ChannelSearchIcon, +}; + +const CHANNEL_ACCENT: Record = { + 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 ; +} + +export function channelScoreAccentColor(iconKey: string): string | undefined { + const k = iconKey?.toLowerCase() ?? ""; + return CHANNEL_ACCENT[k]; +} diff --git a/src/features/report/ui/clinic/ClinicCertificationsBlock.tsx b/src/features/report/ui/clinic/ClinicCertificationsBlock.tsx new file mode 100644 index 0000000..3d11909 --- /dev/null +++ b/src/features/report/ui/clinic/ClinicCertificationsBlock.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/src/features/report/ui/clinic/ClinicInfoStatGrid.tsx b/src/features/report/ui/clinic/ClinicInfoStatGrid.tsx new file mode 100644 index 0000000..921483c --- /dev/null +++ b/src/features/report/ui/clinic/ClinicInfoStatGrid.tsx @@ -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 ( +
+ {rows.map((field, i) => ( + + ))} +
+ ); +} diff --git a/src/features/report/ui/clinic/ClinicLeadDoctorPanel.tsx b/src/features/report/ui/clinic/ClinicLeadDoctorPanel.tsx new file mode 100644 index 0000000..1a3d30f --- /dev/null +++ b/src/features/report/ui/clinic/ClinicLeadDoctorPanel.tsx @@ -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 ( + }> +

{data.leadDoctor.name}

+

{data.leadDoctor.credentials}

+ +
+ ); +} diff --git a/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx b/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx new file mode 100644 index 0000000..50c9f83 --- /dev/null +++ b/src/features/report/ui/clinic/clinicSnapshotStatRows.tsx @@ -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: , + }, + { + label: "의료진", + value: `${data.staffCount}명`, + icon: , + }, + { + label: "강남언니 평점", + value: `${data.overallRating} / 5.0`, + icon: , + }, + { + label: "리뷰 수", + value: formatCompactNumber(data.totalReviews), + icon: , + }, + { + label: "시술 가격대", + value: `${data.priceRange.min} ~ ${data.priceRange.max} ${data.priceRange.currency}`.trim(), + icon: , + }, + { + label: "위치", + value: `${data.location} (${data.nearestStation})`, + icon: , + }, + { + label: "전화", + value: data.phone, + icon: , + }, + { + label: "도메인", + value: data.domain, + icon: , + }, + ]; +} diff --git a/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx b/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx new file mode 100644 index 0000000..2e4337b --- /dev/null +++ b/src/features/report/ui/diagnosis/ProblemDiagnosisCard.tsx @@ -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 ( +
+
+ +
+ +
+
+ +
+
+

{item.category}

+

{item.detail}

+
+
+
+ ); +} diff --git a/src/features/report/ui/diagnosis/severityDotClass.ts b/src/features/report/ui/diagnosis/severityDotClass.ts new file mode 100644 index 0000000..36fca54 --- /dev/null +++ b/src/features/report/ui/diagnosis/severityDotClass.ts @@ -0,0 +1,13 @@ +import type { Severity } from "@/types/severity"; + +/** 다크 섹션 카드 우측 상단 심각도 점 — `index.css` 시맨틱 dot 토큰 */ +export function problemDiagnosisSeverityDotClass(severity: Severity): string { + const map: Record = { + 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]; +} diff --git a/src/features/report/ui/facebook/FacebookPageCard.tsx b/src/features/report/ui/facebook/FacebookPageCard.tsx new file mode 100644 index 0000000..918ebdb --- /dev/null +++ b/src/features/report/ui/facebook/FacebookPageCard.tsx @@ -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 ( +
+
+
+ + {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} +

+
+ +
+

Bio

+

"{page.bio}"

+
+ + + + {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 ( +
+
+ + {account.label} + +
+ +
+
+ +

{account.handle}

+

{account.category}

+ + + + {account.profileLink} + + +
+
+

게시물

+

{formatCompactNumber(account.posts)}

+
+
+

팔로워

+

{formatCompactNumber(account.followers)}

+
+
+

팔로잉

+

{formatCompactNumber(account.following)}

+
+
+ +
+
+ 콘텐츠 포맷 + {account.contentFormat} +
+
+ 릴스 수 + + {account.reelsCount === 0 ? ( + <> + + 0 (미운영) + + ) : ( + account.reelsCount + )} + +
+
+ +

프로필 이미지

+

{account.profilePhoto}

+ + {account.highlights.length > 0 ? ( +
+ +
+ ) : null} + + {account.bio ? ( +
+

Bio

+

{account.bio}

+
+ ) : null} +
+ ); +} diff --git a/src/features/report/ui/instagram/langBadgeClass.ts b/src/features/report/ui/instagram/langBadgeClass.ts new file mode 100644 index 0000000..a16cbd8 --- /dev/null +++ b/src/features/report/ui/instagram/langBadgeClass.ts @@ -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)]"; +} diff --git a/src/features/report/ui/kpi/KpiMetricsTable.tsx b/src/features/report/ui/kpi/KpiMetricsTable.tsx new file mode 100644 index 0000000..e7f3239 --- /dev/null +++ b/src/features/report/ui/kpi/KpiMetricsTable.tsx @@ -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

등록된 KPI가 없습니다.

; + } + + return ( +
+
+
+
+
Metric
+
Current
+
3-Month Target
+
12-Month Target
+
+ + {metrics.map((row, i) => ( +
+
{row.metric}
+
+ {row.current} +
+
+ {row.target3Month} +
+
+ {row.target12Month} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/features/report/ui/kpi/KpiTransformationCtaCard.tsx b/src/features/report/ui/kpi/KpiTransformationCtaCard.tsx new file mode 100644 index 0000000..93f02a0 --- /dev/null +++ b/src/features/report/ui/kpi/KpiTransformationCtaCard.tsx @@ -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 ( +
+
+
+ +
+

+ Start Your Transformation +

+

+ INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다. +

+
+ + 마케팅 기획 + + + +
+
+
+ ); +} diff --git a/src/features/report/ui/kpi/kpiCurrentValueNegative.ts b/src/features/report/ui/kpi/kpiCurrentValueNegative.ts new file mode 100644 index 0000000..c0258bd --- /dev/null +++ b/src/features/report/ui/kpi/kpiCurrentValueNegative.ts @@ -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("측정 불가") + ); +} diff --git a/src/features/report/ui/otherChannels/OtherChannelsList.tsx b/src/features/report/ui/otherChannels/OtherChannelsList.tsx new file mode 100644 index 0000000..e9ad3ef --- /dev/null +++ b/src/features/report/ui/otherChannels/OtherChannelsList.tsx @@ -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 ( +
+
+

기타 채널 현황

+
+
+ {channels.map((ch, i) => ( + + ))} +
+
+ ); +} diff --git a/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx b/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx new file mode 100644 index 0000000..09735b0 --- /dev/null +++ b/src/features/report/ui/otherChannels/WebsiteTechAuditBlock.tsx @@ -0,0 +1,78 @@ +import AlertCircleIcon from "@/assets/icons/alert-circle.svg?react"; +import CheckCircleIcon from "@/assets/report/check-circle.svg?react"; +import GlobeIcon from "@/assets/report/globe.svg?react"; +import { PixelInstallCard } from "@/components/card/PixelInstallCard"; +import type { WebsiteAudit } from "@/types/otherChannels"; + +export type WebsiteTechAuditBlockProps = { + website: WebsiteAudit; +}; + +export function WebsiteTechAuditBlock({ website }: WebsiteTechAuditBlockProps) { + 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 ( + <> +
+
+
+ + ); +} diff --git a/src/features/report/ui/overview/OverviewHeroColumn.tsx b/src/features/report/ui/overview/OverviewHeroColumn.tsx new file mode 100644 index 0000000..85d3957 --- /dev/null +++ b/src/features/report/ui/overview/OverviewHeroColumn.tsx @@ -0,0 +1,39 @@ +import type { ReportOverviewData } from "@/features/report/types/reportOverview"; +import { OverviewMetaChips } from "@/features/report/ui/overview/OverviewMetaChips"; + +export type OverviewHeroColumnProps = { + data: ReportOverviewData; +}; + +export function OverviewHeroColumn({ data }: OverviewHeroColumnProps) { + const { clinicName, clinicNameEn, date, targetUrl, location, logoImage } = data; + + return ( +
+

+ Marketing Intelligence Report +

+ + {logoImage ? ( +
+ {`${clinicName} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> +
+ ) : null} + +

+ {clinicName} +

+ +

{clinicNameEn}

+ + +
+ ); +} diff --git a/src/features/report/ui/overview/OverviewMetaChips.tsx b/src/features/report/ui/overview/OverviewMetaChips.tsx new file mode 100644 index 0000000..20a9740 --- /dev/null +++ b/src/features/report/ui/overview/OverviewMetaChips.tsx @@ -0,0 +1,31 @@ +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 { OVERVIEW_META_CHIP_CLASS } from "@/features/report/ui/overview/overviewSectionStyles"; + +export type OverviewMetaChipsProps = { + date: string; + targetUrl: string; + location: string; +}; + +export function OverviewMetaChips({ date, targetUrl, location }: OverviewMetaChipsProps) { + return ( +
+ + + {date} + + + + + {targetUrl} + + + + + {location} + +
+ ); +} diff --git a/src/features/report/ui/overview/OverviewScorePanel.tsx b/src/features/report/ui/overview/OverviewScorePanel.tsx new file mode 100644 index 0000000..102f4cb --- /dev/null +++ b/src/features/report/ui/overview/OverviewScorePanel.tsx @@ -0,0 +1,18 @@ +import { ScoreRing } from "@/components/rating/ScoreRing"; + +export type OverviewScorePanelProps = { + overallScore: number; +}; + +export function OverviewScorePanel({ overallScore }: OverviewScorePanelProps) { + return ( +
+
+

+ Overall Score +

+ +
+
+ ); +} diff --git a/src/features/report/ui/overview/overviewSectionStyles.ts b/src/features/report/ui/overview/overviewSectionStyles.ts new file mode 100644 index 0000000..29cfb95 --- /dev/null +++ b/src/features/report/ui/overview/overviewSectionStyles.ts @@ -0,0 +1,5 @@ +export const OVERVIEW_SECTION_BG_CLASS = + "bg-[radial-gradient(ellipse_at_top_left,var(--color-hero-wash-start),transparent_50%),radial-gradient(ellipse_at_bottom_right,var(--color-marketing-blush),transparent_50%),radial-gradient(ellipse_at_center,var(--color-lavender-100),transparent_60%)]"; + +export const OVERVIEW_META_CHIP_CLASS = + "inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 body-14-medium text-neutral-80 max-w-full"; diff --git a/src/features/report/ui/roadmap/RoadmapMonthCard.tsx b/src/features/report/ui/roadmap/RoadmapMonthCard.tsx new file mode 100644 index 0000000..50f0be8 --- /dev/null +++ b/src/features/report/ui/roadmap/RoadmapMonthCard.tsx @@ -0,0 +1,34 @@ +import type { RoadmapMonth } from "@/features/report/types/roadmap"; +import { RoadmapTaskItem } from "@/features/report/ui/roadmap/RoadmapTaskItem"; + +export type RoadmapMonthCardProps = { + month: RoadmapMonth; + index: number; +}; + +export function RoadmapMonthCard({ month, index }: RoadmapMonthCardProps) { + const baseDelay = index * 150; + + return ( +
+
+
+ {month.month} +
+
+

{month.title}

+

{month.subtitle}

+
+
+ +
    + {month.tasks.map((task, j) => ( + + ))} +
+
+ ); +} diff --git a/src/features/report/ui/roadmap/RoadmapMonthsGrid.tsx b/src/features/report/ui/roadmap/RoadmapMonthsGrid.tsx new file mode 100644 index 0000000..305abe7 --- /dev/null +++ b/src/features/report/ui/roadmap/RoadmapMonthsGrid.tsx @@ -0,0 +1,20 @@ +import type { RoadmapMonth } from "@/features/report/types/roadmap"; +import { RoadmapMonthCard } from "@/features/report/ui/roadmap/RoadmapMonthCard"; + +export type RoadmapMonthsGridProps = { + months: RoadmapMonth[]; +}; + +export function RoadmapMonthsGrid({ months }: RoadmapMonthsGridProps) { + if (months.length === 0) { + return

등록된 로드맵이 없습니다.

; + } + + return ( +
+ {months.map((m, i) => ( + + ))} +
+ ); +} diff --git a/src/features/report/ui/roadmap/RoadmapTaskItem.tsx b/src/features/report/ui/roadmap/RoadmapTaskItem.tsx new file mode 100644 index 0000000..05e38a1 --- /dev/null +++ b/src/features/report/ui/roadmap/RoadmapTaskItem.tsx @@ -0,0 +1,37 @@ +import CheckCircleIcon from "@/assets/report/check-circle.svg?react"; +import type { RoadmapTask } from "@/features/report/types/roadmap"; + +export type RoadmapTaskItemProps = { + task: RoadmapTask; + animationDelayMs: number; +}; + +export function RoadmapTaskItem({ task, animationDelayMs }: RoadmapTaskItemProps) { + return ( +
  • + {task.completed ? ( + + ) : ( +
    + )} + + {task.task} + +
  • + ); +} diff --git a/src/features/report/ui/transformation/NewChannelProposalsTable.tsx b/src/features/report/ui/transformation/NewChannelProposalsTable.tsx new file mode 100644 index 0000000..0d41f64 --- /dev/null +++ b/src/features/report/ui/transformation/NewChannelProposalsTable.tsx @@ -0,0 +1,49 @@ +import type { NewChannelProposal } from "@/features/report/types/transformationProposal"; +import { newChannelPriorityClass } from "@/features/report/ui/transformation/newChannelPriorityClass"; + +export type NewChannelProposalsTableProps = { + rows: NewChannelProposal[]; +}; + +export function NewChannelProposalsTable({ rows }: NewChannelProposalsTableProps) { + if (rows.length === 0) return null; + + return ( +
    + + + + + + + + + + {rows.map((ch, i) => ( + + + + + + ))} + +
    + 채널 + + 우선순위 + + 근거 +
    {ch.channel} + + {ch.priority} + + {ch.rationale}
    +
    + ); +} diff --git a/src/features/report/ui/transformation/PlatformStrategyCard.tsx b/src/features/report/ui/transformation/PlatformStrategyCard.tsx new file mode 100644 index 0000000..d98d24d --- /dev/null +++ b/src/features/report/ui/transformation/PlatformStrategyCard.tsx @@ -0,0 +1,46 @@ +import ArrowUpRightIcon from "@/assets/report/arrow-up-right.svg?react"; +import type { PlatformStrategy } from "@/features/report/types/transformationProposal"; +import { platformStrategyIconNode } from "@/features/report/ui/transformation/platformStrategyIcon"; + +export type PlatformStrategyCardProps = { + strategy: PlatformStrategy; + index: number; +}; + +export function PlatformStrategyCard({ strategy, index }: PlatformStrategyCardProps) { + return ( +
    +
    +
    + {platformStrategyIconNode(strategy.icon)} +
    +

    {strategy.platform}

    +
    + +
    + + {strategy.currentMetric} + + + + {strategy.targetMetric} + +
    + +
      + {strategy.strategies.map((s, i) => ( +
    • + +
      +

      {s.strategy}

      +

      {s.detail}

      +
      +
    • + ))} +
    +
    + ); +} diff --git a/src/features/report/ui/transformation/TransformationTabbedView.tsx b/src/features/report/ui/transformation/TransformationTabbedView.tsx new file mode 100644 index 0000000..9cc9478 --- /dev/null +++ b/src/features/report/ui/transformation/TransformationTabbedView.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { ComparisonRow } from "@/components/compare/ComparisonRow"; +import type { TransformationProposal } from "@/features/report/types/transformationProposal"; +import { NewChannelProposalsTable } from "@/features/report/ui/transformation/NewChannelProposalsTable"; +import { PlatformStrategyCard } from "@/features/report/ui/transformation/PlatformStrategyCard"; +import { + TRANSFORMATION_TABS, + type TransformationTabKey, +} from "@/features/report/ui/transformation/transformationTabs"; + +export type TransformationTabbedViewProps = { + data: TransformationProposal; +}; + +export function TransformationTabbedView({ data }: TransformationTabbedViewProps) { + const [activeTab, setActiveTab] = useState("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 ? ( +
    + {data.linkedUrls.map((link) => ( + + + {link.label} + + ))} +
    + ) : 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>(new Map()); + + // activeId 변경 시 활성 탭을 가로 스크롤 중앙으로 이동 + useEffect(() => { + if (!scrollActiveIntoView || activeId === undefined) return; + const el = tabRefs.current.get(activeId); + if (el && navRef.current) { + el.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + } + }, [activeId, scrollActiveIntoView]); + + if (!items.length) return null; + + const tabClass = (active: boolean) => + [ + "shrink-0 px-4 py-3 body-14-medium transition-colors whitespace-nowrap border-b-2 cursor-pointer", + active ? "border-violet-700 text-navy-900" : "border-transparent text-neutral-70 hover:text-navy-900", + ].join(" "); + + const scrollToTarget = (targetId: string) => { + document.getElementById(targetId)?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/lib/formatNumber.ts b/src/lib/formatNumber.ts new file mode 100644 index 0000000..6730f7f --- /dev/null +++ b/src/lib/formatNumber.ts @@ -0,0 +1,6 @@ +/** 1_000_000 → 1.00M, 18_840 → 18K 등 짧은 표기 */ +export function formatCompactNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`; + return n.toLocaleString(); +} diff --git a/src/lib/safeUrl.ts b/src/lib/safeUrl.ts new file mode 100644 index 0000000..d7260c0 --- /dev/null +++ b/src/lib/safeUrl.ts @@ -0,0 +1,7 @@ +/** 상대·스킴 없는 URL에 https:// 붙임 */ +export function safeUrl(url: string): string { + const t = url.trim(); + if (!t) return t; + if (/^https?:\/\//i.test(t)) return t; + return `https://${t}`; +} diff --git a/src/pages/Report.tsx b/src/pages/Report.tsx new file mode 100644 index 0000000..c4e7891 --- /dev/null +++ b/src/pages/Report.tsx @@ -0,0 +1,40 @@ +import { useParams } from "react-router-dom"; +import { useReportSubNav } from "@/features/report/hooks/useReportSubNav"; +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 function ReportPage() { + const { id } = useParams<{ id: string }>(); + useReportSubNav(); + + return ( +
    +

    + Report ID: {id} +

    + +
    + + + + + + + + + + + +
    +
    + ); +} diff --git a/src/types/brandConsistency.ts b/src/types/brandConsistency.ts new file mode 100644 index 0000000..fc2da6d --- /dev/null +++ b/src/types/brandConsistency.ts @@ -0,0 +1,13 @@ +/** 전 채널 브랜드 일관성(로고·도메인·바이오 등) 아코디언 UI용 — 리포트 외 재사용 가능 */ +export type BrandChannelValue = { + channel: string; + value: string; + isCorrect: boolean; +}; + +export type BrandInconsistency = { + field: string; + values: BrandChannelValue[]; + impact: string; + recommendation: string; +}; diff --git a/src/types/otherChannels.ts b/src/types/otherChannels.ts new file mode 100644 index 0000000..2d7fbba --- /dev/null +++ b/src/types/otherChannels.ts @@ -0,0 +1,27 @@ +export type OtherChannelStatus = "active" | "inactive" | "unknown" | "not_found"; + +export type OtherChannel = { + name: string; + status: OtherChannelStatus; + details: string; + url?: string; +}; + +export type TrackingPixel = { + name: string; + installed: boolean; + details?: string; +}; + +export type WebsiteAudit = { + primaryDomain: string; + additionalDomains: { domain: string; purpose: string }[]; + snsLinksOnSite: boolean; + trackingPixels: TrackingPixel[]; + mainCTA: string; +}; + +export type OtherChannelsReport = { + channels: OtherChannel[]; + website: WebsiteAudit; +}; diff --git a/src/types/severity.ts b/src/types/severity.ts new file mode 100644 index 0000000..10e7a58 --- /dev/null +++ b/src/types/severity.ts @@ -0,0 +1 @@ +export type Severity = "critical" | "warning" | "good" | "excellent" | "unknown"; From 6ea81efd90e8517ba951b4f26c3552893884e0f1 Mon Sep 17 00:00:00 2001 From: minheon Date: Wed, 1 Apr 2026 11:18:54 +0900 Subject: [PATCH 2/2] =?UTF-8?q?[fix]=20=ED=95=98=EB=8B=A8=20nav=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/layouts/PageNavigator.tsx | 59 +++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 13 deletions(-) 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() { > {/* 이전 페이지 */}