+
+
Channel Integration
+
+ 채널 연결
+
+
+ 소셜 미디어와 플랫폼을 연결하여 콘텐츠를 자동으로 배포하고 성과를 추적하세요.
+
+
+
+
+
0 ? 'bg-[#6C5CE7]' : 'bg-slate-300'}`} />
+
+ {connectedCount} / {CHANNELS.length} 연결됨
+
+
+ {connectedCount > 0 && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/channelconnect/ui/index.tsx b/src/features/channelconnect/ui/index.tsx
new file mode 100644
index 0000000..99b68e5
--- /dev/null
+++ b/src/features/channelconnect/ui/index.tsx
@@ -0,0 +1,7 @@
+import { ChannelConnectTitle } from './ChannelConnectTitle';
+import { ChannelConnectSection } from './ChannelConnectSection';
+
+export {
+ ChannelConnectTitle,
+ ChannelConnectSection,
+};
diff --git a/src/features/channelconnect/utils/channelIconMap.ts b/src/features/channelconnect/utils/channelIconMap.ts
new file mode 100644
index 0000000..69592b2
--- /dev/null
+++ b/src/features/channelconnect/utils/channelIconMap.ts
@@ -0,0 +1,18 @@
+import type { ComponentType } from 'react';
+import {
+ YoutubeFilled,
+ InstagramFilled,
+ FacebookFilled,
+ GlobeFilled,
+ TiktokFilled,
+} from '@/components/icons/FilledIcons';
+
+type IconComponent = ComponentType<{ size?: number; className?: string; color?: string; style?: { color?: string; [key: string]: unknown } }>;
+
+export const CHANNEL_ICON_MAP: Record
= {
+ youtube: YoutubeFilled,
+ instagram: InstagramFilled,
+ facebook: FacebookFilled,
+ globe: GlobeFilled,
+ tiktok: TiktokFilled,
+};
diff --git a/src/features/distribution/constants/distribution.ts b/src/features/distribution/constants/distribution.ts
new file mode 100644
index 0000000..116aa2f
--- /dev/null
+++ b/src/features/distribution/constants/distribution.ts
@@ -0,0 +1,30 @@
+/** 배포 페이지 상수 */
+
+import {
+ YoutubeFilled,
+ InstagramFilled,
+ FacebookFilled,
+ GlobeFilled,
+ TiktokFilled,
+} from '@/components/icons/FilledIcons';
+import type { ChannelTarget, MockContent } from '../types';
+
+export const MOCK_CONTENT: MockContent = {
+ title: '한번에 성공하는 코성형, VIEW의 비결',
+ description: '코성형은 얼굴의 중심을 결정하는 중요한 수술입니다. VIEW 성형외과는 21년간 축적된 노하우를 바탕으로 자연스러운 결과를 만들어냅니다.',
+ type: 'video',
+ duration: '0:58',
+ aspectRatio: '9:16',
+ tags: ['코성형', '뷰성형외과', '강남성형외과', '코수술', '자연스러운코'],
+ thumbnail: null,
+};
+
+export const INITIAL_CHANNELS: ChannelTarget[] = [
+ { id: 'youtube', name: 'YouTube Shorts', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', connected: true, selected: true, status: 'ready', format: 'Shorts (9:16)' },
+ { id: 'instagram_kr', name: 'Instagram Reels', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: true, status: 'ready', format: 'Reels (9:16)' },
+ { id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', connected: true, selected: false, status: 'ready', format: 'Reels (9:16)' },
+ { id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
+ { id: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
+ { id: 'naver_blog', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', connected: false, selected: false, status: 'ready', format: 'Blog Post' },
+ { id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, selected: false, status: 'ready', format: 'Video Post' },
+];
diff --git a/src/features/distribution/hooks/useDistribution.ts b/src/features/distribution/hooks/useDistribution.ts
new file mode 100644
index 0000000..61e6d53
--- /dev/null
+++ b/src/features/distribution/hooks/useDistribution.ts
@@ -0,0 +1,61 @@
+import { useState, useCallback } from 'react';
+import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution';
+
+export function useDistribution() {
+ const [channels, setChannels] = useState(INITIAL_CHANNELS);
+ const [title, setTitle] = useState(MOCK_CONTENT.title);
+ const [description, setDescription] = useState(MOCK_CONTENT.description);
+ const [tags, setTags] = useState(MOCK_CONTENT.tags);
+ const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
+ const [scheduleDate, setScheduleDate] = useState('');
+ const [scheduleHour, setScheduleHour] = useState(9);
+ const [scheduleMinute, setScheduleMinute] = useState(0);
+ const [schedulePeriod, setSchedulePeriod] = useState<'AM' | 'PM'>('AM');
+ const [isPublishing, setIsPublishing] = useState(false);
+
+ const selectedChannels = channels.filter(c => c.connected && c.selected);
+ const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
+
+ const toggleChannel = useCallback((id: string) => {
+ setChannels(prev => prev.map(c =>
+ c.id === id && c.connected ? { ...c, selected: !c.selected } : c
+ ));
+ }, []);
+
+ const handlePublish = useCallback(() => {
+ setIsPublishing(true);
+
+ const selected = channels.filter(c => c.connected && c.selected);
+ selected.forEach((ch, i) => {
+ setTimeout(() => {
+ setChannels(prev => prev.map(c =>
+ c.id === ch.id ? { ...c, status: 'publishing' } : c
+ ));
+ }, i * 1500);
+
+ setTimeout(() => {
+ setChannels(prev => prev.map(c =>
+ c.id === ch.id ? { ...c, status: 'published' } : c
+ ));
+ if (i === selected.length - 1) setIsPublishing(false);
+ }, (i + 1) * 1500 + 500);
+ });
+ }, [channels]);
+
+ return {
+ channels,
+ title, setTitle,
+ description, setDescription,
+ tags,
+ scheduleMode, setScheduleMode,
+ scheduleDate, setScheduleDate,
+ scheduleHour, setScheduleHour,
+ scheduleMinute, setScheduleMinute,
+ schedulePeriod, setSchedulePeriod,
+ isPublishing,
+ selectedChannels,
+ allPublished,
+ toggleChannel,
+ handlePublish,
+ };
+}
diff --git a/src/features/distribution/types/index.ts b/src/features/distribution/types/index.ts
new file mode 100644
index 0000000..99e01f4
--- /dev/null
+++ b/src/features/distribution/types/index.ts
@@ -0,0 +1,25 @@
+import type { ComponentType } from 'react';
+
+export type DistributeStatus = 'ready' | 'publishing' | 'published' | 'failed';
+
+export interface ChannelTarget {
+ id: string;
+ name: string;
+ icon: ComponentType<{ size?: number; className?: string; style?: { color?: string } }>;
+ brandColor: string;
+ bgColor: string;
+ connected: boolean;
+ selected: boolean;
+ status: DistributeStatus;
+ format: string;
+}
+
+export interface MockContent {
+ title: string;
+ description: string;
+ type: 'video';
+ duration: string;
+ aspectRatio: string;
+ tags: string[];
+ thumbnail: string | null;
+}
diff --git a/src/features/distribution/ui/ChannelSelectSection.tsx b/src/features/distribution/ui/ChannelSelectSection.tsx
new file mode 100644
index 0000000..2e95dd2
--- /dev/null
+++ b/src/features/distribution/ui/ChannelSelectSection.tsx
@@ -0,0 +1,86 @@
+import { motion } from 'motion/react';
+import type { ChannelTarget } from '../types';
+
+interface ChannelSelectSectionProps {
+ channels: ChannelTarget[];
+ toggleChannel: (id: string) => void;
+}
+
+export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) {
+ return (
+
+
배포 채널 선택
+
+ {channels.map(ch => {
+ const Icon = ch.icon;
+ return (
+
+
+
+
+
+
+
+
+
+ {ch.name}
+ {!ch.connected && (
+
+ 미연결
+
+ )}
+
+
{ch.format}
+
+
+
+ {ch.status === 'publishing' && (
+
+ )}
+ {ch.status === 'published' && (
+
+ )}
+ {ch.status === 'failed' && (
+
+ !
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/distribution/ui/ContentPreviewSection.tsx b/src/features/distribution/ui/ContentPreviewSection.tsx
new file mode 100644
index 0000000..7e5deeb
--- /dev/null
+++ b/src/features/distribution/ui/ContentPreviewSection.tsx
@@ -0,0 +1,57 @@
+import { VideoFilled } from '@/components/icons/FilledIcons';
+import { MOCK_CONTENT } from '../constants/distribution';
+
+interface ContentPreviewSectionProps {
+ title: string;
+ setTitle: (v: string) => void;
+ description: string;
+ setDescription: (v: string) => void;
+ tags: string[];
+}
+
+export function ContentPreviewSection({ title, setTitle, description, setDescription, tags }: ContentPreviewSectionProps) {
+ return (
+
+
콘텐츠
+
+
+
+
{MOCK_CONTENT.aspectRatio}
+
{MOCK_CONTENT.duration}
+
+
+
+
+ setTitle(e.target.value)}
+ className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
+ />
+
+
+
+
+
+
+
+
+
+ {tags.map(tag => (
+
+ #{tag}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/distribution/ui/DistributionSection.tsx b/src/features/distribution/ui/DistributionSection.tsx
new file mode 100644
index 0000000..e91f0ed
--- /dev/null
+++ b/src/features/distribution/ui/DistributionSection.tsx
@@ -0,0 +1,63 @@
+import { useDistribution } from '../hooks/useDistribution';
+import { DistributionTitle } from './DistributionTitle';
+import { ContentPreviewSection } from './ContentPreviewSection';
+import { ChannelSelectSection } from './ChannelSelectSection';
+import { SchedulePublishSection } from './SchedulePublishSection';
+
+export function DistributionSection() {
+ const {
+ channels,
+ title, setTitle,
+ description, setDescription,
+ tags,
+ scheduleMode, setScheduleMode,
+ scheduleDate, setScheduleDate,
+ scheduleHour, setScheduleHour,
+ scheduleMinute, setScheduleMinute,
+ schedulePeriod, setSchedulePeriod,
+ isPublishing,
+ selectedChannels,
+ allPublished,
+ toggleChannel,
+ handlePublish,
+ } = useDistribution();
+
+ return (
+
+ );
+}
diff --git a/src/features/distribution/ui/DistributionTitle.tsx b/src/features/distribution/ui/DistributionTitle.tsx
new file mode 100644
index 0000000..163e862
--- /dev/null
+++ b/src/features/distribution/ui/DistributionTitle.tsx
@@ -0,0 +1,16 @@
+export function DistributionTitle() {
+ return (
+
+
+
+
Content Distribution
+
+ 콘텐츠 배포
+
+
+ 제작된 콘텐츠를 연결된 채널에 동시 배포합니다.
+
+
+
+ );
+}
diff --git a/src/features/distribution/ui/SchedulePublishSection.tsx b/src/features/distribution/ui/SchedulePublishSection.tsx
new file mode 100644
index 0000000..7cb85a3
--- /dev/null
+++ b/src/features/distribution/ui/SchedulePublishSection.tsx
@@ -0,0 +1,165 @@
+import { motion } from 'motion/react';
+import { ShareFilled } from '@/components/icons/FilledIcons';
+import type { ChannelTarget } from '../types';
+
+interface SchedulePublishSectionProps {
+ scheduleMode: 'now' | 'scheduled';
+ setScheduleMode: (v: 'now' | 'scheduled') => void;
+ scheduleDate: string;
+ setScheduleDate: (v: string) => void;
+ scheduleHour: number;
+ setScheduleHour: (fn: (h: number) => number) => void;
+ scheduleMinute: number;
+ setScheduleMinute: (fn: (m: number) => number) => void;
+ schedulePeriod: 'AM' | 'PM';
+ setSchedulePeriod: (v: 'AM' | 'PM') => void;
+ isPublishing: boolean;
+ selectedChannels: ChannelTarget[];
+ allPublished: boolean;
+ handlePublish: () => void;
+}
+
+export function SchedulePublishSection({
+ scheduleMode, setScheduleMode,
+ scheduleDate, setScheduleDate,
+ scheduleHour, setScheduleHour,
+ scheduleMinute, setScheduleMinute,
+ schedulePeriod, setSchedulePeriod,
+ isPublishing,
+ selectedChannels,
+ allPublished,
+ handlePublish,
+}: SchedulePublishSectionProps) {
+ return (
+
+
배포 시간
+
+ {([
+ { key: 'now' as const, label: '즉시 배포' },
+ { key: 'scheduled' as const, label: '예약 배포' },
+ ]).map(opt => (
+
+ ))}
+
+
+ {scheduleMode === 'scheduled' && (
+
+
+
+ setScheduleDate(e.target.value)}
+ className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 focus:outline-none focus:border-[#6C5CE7] transition-all appearance-none"
+ />
+
+
+
+
+
+
+
+
+ {String(scheduleHour).padStart(2, '0')}
+
+
+
+
+
:
+
+
+
+
+ {String(scheduleMinute).padStart(2, '0')}
+
+
+
+
+
+ {(['AM', 'PM'] as const).map(p => (
+
+ ))}
+
+
+
+
+ )}
+
+ {!allPublished ? (
+
+ ) : (
+
+
+ 배포 완료
+
+ {selectedChannels.length}개 채널에 성공적으로 배포되었습니다
+
+
+ )}
+
+ );
+}
diff --git a/src/features/distribution/ui/index.tsx b/src/features/distribution/ui/index.tsx
new file mode 100644
index 0000000..6a651e5
--- /dev/null
+++ b/src/features/distribution/ui/index.tsx
@@ -0,0 +1,13 @@
+import { DistributionTitle } from './DistributionTitle';
+import { ContentPreviewSection } from './ContentPreviewSection';
+import { ChannelSelectSection } from './ChannelSelectSection';
+import { SchedulePublishSection } from './SchedulePublishSection';
+import { DistributionSection } from './DistributionSection';
+
+export {
+ DistributionTitle,
+ ContentPreviewSection,
+ ChannelSelectSection,
+ SchedulePublishSection,
+ DistributionSection,
+};
diff --git a/src/features/performance/constants/performance.ts b/src/features/performance/constants/performance.ts
new file mode 100644
index 0000000..0f4e69f
--- /dev/null
+++ b/src/features/performance/constants/performance.ts
@@ -0,0 +1,83 @@
+/** 성과 대시보드 상수 */
+
+import {
+ YoutubeFilled,
+ InstagramFilled,
+ FacebookFilled,
+ GlobeFilled,
+ TiktokFilled,
+} from '@/components/icons/FilledIcons';
+import type { ChannelMetric, ContentPerformance } from '../types';
+
+export const CHANNELS: ChannelMetric[] = [
+ { id: 'youtube', name: 'YouTube', icon: YoutubeFilled, brandColor: '#FF0000', bgColor: '#FFF0F0', followers: '103K', followersDelta: '+2.1K', views: '270K', viewsDelta: '+18%', engagement: '4.2%', engagementDelta: '+0.8%', posts: 12, score: 65 },
+ { id: 'instagram_kr', name: 'Instagram KR', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '14K', followersDelta: '+890', views: '45K', viewsDelta: '+32%', engagement: '3.1%', engagementDelta: '+1.2%', posts: 24, score: 35 },
+ { id: 'instagram_en', name: 'Instagram EN', icon: InstagramFilled, brandColor: '#E1306C', bgColor: '#FFF0F5', followers: '68.8K', followersDelta: '+1.2K', views: '120K', viewsDelta: '+8%', engagement: '5.6%', engagementDelta: '+0.3%', posts: 18, score: 55 },
+ { id: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', followers: '0', followersDelta: 'NEW', views: '0', viewsDelta: '-', engagement: '0%', engagementDelta: '-', posts: 0, score: 0 },
+ { id: 'facebook', name: 'Facebook', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', followers: '341', followersDelta: '+12', views: '2.1K', viewsDelta: '-5%', engagement: '0.8%', engagementDelta: '-0.2%', posts: 6, score: 40 },
+ { id: 'naver', name: 'Naver Blog', icon: GlobeFilled, brandColor: '#03C75A', bgColor: '#F0FFF5', followers: '-', followersDelta: '-', views: '8.2K', viewsDelta: '+45%', engagement: '2.4%', engagementDelta: '+1.1%', posts: 8, score: 72 },
+];
+
+export const TOP_CONTENT: ContentPerformance[] = [
+ { id: '1', title: '한번에 성공하는 코성형, VIEW의 비결', channel: 'YouTube', type: 'video', views: '12.4K', likes: '342', comments: '28', ctr: '8.2%', publishedAt: '3일 전' },
+ { id: '2', title: '안면윤곽 수술 종류와 회복기간', channel: 'Naver Blog', type: 'blog', views: '3.2K', likes: '-', comments: '12', ctr: '12.5%', publishedAt: '5일 전' },
+ { id: '3', title: 'Reel: 윤곽 전후 변화', channel: 'Instagram KR', type: 'social', views: '8.7K', likes: '567', comments: '45', ctr: '6.1%', publishedAt: '2일 전' },
+ { id: '4', title: 'Shorts: 사각턱 축소 과정', channel: 'YouTube', type: 'video', views: '5.1K', likes: '189', comments: '15', ctr: '4.8%', publishedAt: '4일 전' },
+ { id: '5', title: '코성형 가이드: 내 얼굴에 맞는 코', channel: 'Naver Blog', type: 'blog', views: '2.8K', likes: '-', comments: '8', ctr: '15.2%', publishedAt: '6일 전' },
+];
+
+export const OVERVIEW_STATS = [
+ { label: '총 노출', value: '445K', delta: '+24%', positive: true },
+ { label: '총 조회', value: '89.2K', delta: '+18%', positive: true },
+ { label: '평균 참여율', value: '3.8%', delta: '+0.6%', positive: true },
+ { label: '콘텐츠 발행', value: '68건', delta: '+12건', positive: true },
+ { label: '신규 팔로워', value: '+4.3K', delta: '+32%', positive: true },
+ { label: '전환 (상담)', value: '47건', delta: '+15건', positive: true },
+];
+
+export const FUNNEL_STEPS = [
+ { label: '노출', labelEn: 'Impressions', value: 445000, display: '445K', color: '#6C5CE7' },
+ { label: '클릭', labelEn: 'Clicks', value: 89200, display: '89.2K', color: '#7C6DD8' },
+ { label: '웹사이트 유입', labelEn: 'Website Visits', value: 12400, display: '12.4K', color: '#9B8AD4' },
+ { label: '상담 문의', labelEn: 'Inquiries', value: 478, display: '478', color: '#B8A9E8' },
+ { label: '예약 전환', labelEn: 'Conversions', value: 47, display: '47', color: '#D5CDF5' },
+];
+
+export const CHANNEL_TREND = [
+ { week: 'W1', youtube: 85, instagram: 32, naver: 18, facebook: 8 },
+ { week: 'W2', youtube: 92, instagram: 41, naver: 24, facebook: 7 },
+ { week: 'W3', youtube: 78, instagram: 55, naver: 31, facebook: 9 },
+ { week: 'W4', youtube: 105, instagram: 68, naver: 38, facebook: 6 },
+];
+
+export const TREND_CHANNELS = [
+ { key: 'youtube' as const, label: 'YouTube', color: 'rgba(155,138,212,0.35)' },
+ { key: 'instagram' as const, label: 'Instagram', color: 'rgba(212,168,186,0.3)' },
+ { key: 'naver' as const, label: 'Naver', color: 'rgba(160,200,180,0.3)' },
+ { key: 'facebook' as const, label: 'Facebook', color: 'rgba(160,180,220,0.25)' },
+];
+
+export const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
+export const TIME_SLOTS = ['오전 (6-12)', '오후 (12-18)', '저녁 (18-24)', '심야 (0-6)'];
+
+export const HEATMAP_DATA = [
+ [3, 7, 8, 2],
+ [4, 6, 9, 1],
+ [5, 8, 7, 2],
+ [6, 9, 8, 1],
+ [4, 7, 10, 3],
+ [2, 5, 6, 4],
+ [1, 4, 5, 3],
+];
+
+export const FUNNEL_INSIGHT = '병목 구간: 클릭 → 웹사이트 유입 전환율 13.9% — 랜딩 페이지 최적화가 필요합니다. 업계 평균 20% 대비 낮음.';
+
+export const TREND_INSIGHT = '성장 채널: Instagram 조회수 +112% (W1→W4). Naver Blog +111% 동반 성장. YouTube는 안정적 유지.';
+
+export const HEATMAP_INSIGHT = '최적 시간: 금요일 저녁 (18-24시) 참여율 최고. 평일 오후 (12-18시)가 전반적으로 높음. 주말 오전은 피하세요.';
+
+export const AI_RECOMMENDATIONS = [
+ { title: 'YouTube Shorts 확대', desc: 'Shorts 조회수가 Long-form 대비 3.2배 높습니다. 주 3회 이상 Shorts 업로드를 권장합니다.' },
+ { title: 'Instagram Reels 시작', desc: 'KR 계정에 Reels 0개입니다. 경쟁 병원 평균 주 5개 — 즉시 시작이 필요합니다.' },
+ { title: '랜딩 페이지 최적화', desc: '클릭→유입 전환율 13.9%로 업계 평균 20% 대비 낮음. CTA 버튼 위치와 페이지 속도 개선 필요.' },
+];
diff --git a/src/features/performance/hooks/usePerformance.ts b/src/features/performance/hooks/usePerformance.ts
new file mode 100644
index 0000000..ab990b2
--- /dev/null
+++ b/src/features/performance/hooks/usePerformance.ts
@@ -0,0 +1,11 @@
+import { useState } from 'react';
+import { FUNNEL_STEPS, CHANNEL_TREND } from '../constants/performance';
+
+export function usePerformance() {
+ const [period, setPeriod] = useState<'7d' | '30d' | '90d'>('30d');
+
+ const funnelMax = FUNNEL_STEPS[0].value;
+ const trendMax = Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook]));
+
+ return { period, setPeriod, funnelMax, trendMax };
+}
diff --git a/src/features/performance/types/index.ts b/src/features/performance/types/index.ts
new file mode 100644
index 0000000..18d89c0
--- /dev/null
+++ b/src/features/performance/types/index.ts
@@ -0,0 +1,29 @@
+import type { ComponentType } from 'react';
+
+export interface ChannelMetric {
+ id: string;
+ name: string;
+ icon: ComponentType<{ size?: number; className?: string; style?: { color?: string } }>;
+ brandColor: string;
+ bgColor: string;
+ followers: string;
+ followersDelta: string;
+ views: string;
+ viewsDelta: string;
+ engagement: string;
+ engagementDelta: string;
+ posts: number;
+ score: number;
+}
+
+export interface ContentPerformance {
+ id: string;
+ title: string;
+ channel: string;
+ type: 'video' | 'blog' | 'social';
+ views: string;
+ likes: string;
+ comments: string;
+ ctr: string;
+ publishedAt: string;
+}
diff --git a/src/features/performance/ui/AIRecommendationSection.tsx b/src/features/performance/ui/AIRecommendationSection.tsx
new file mode 100644
index 0000000..efe2e62
--- /dev/null
+++ b/src/features/performance/ui/AIRecommendationSection.tsx
@@ -0,0 +1,17 @@
+import { AI_RECOMMENDATIONS } from '../constants/performance';
+
+export function AIRecommendationSection() {
+ return (
+
+
AI 개선 추천
+
+ {AI_RECOMMENDATIONS.map((rec, i) => (
+
+
{rec.title}
+
{rec.desc}
+
+ ))}
+
+
+ );
+}
diff --git a/src/features/performance/ui/ChannelPerformanceSection.tsx b/src/features/performance/ui/ChannelPerformanceSection.tsx
new file mode 100644
index 0000000..3855b84
--- /dev/null
+++ b/src/features/performance/ui/ChannelPerformanceSection.tsx
@@ -0,0 +1,61 @@
+import { motion } from 'motion/react';
+import { CHANNELS } from '../constants/performance';
+
+function MetricCell({ label, value, delta }: { label: string; value: string; delta: string }) {
+ const isPositive = delta.startsWith('+');
+ const isNew = delta === 'NEW' || delta === '-';
+ return (
+
+
{label}
+
{value}
+
{delta}
+
+ );
+}
+
+export function ChannelPerformanceSection() {
+ return (
+
+
채널별 성과
+
+ {CHANNELS.map((ch, i) => {
+ const Icon = ch.icon;
+ return (
+
+
+
+
+
+
+
{ch.name}
+
{ch.posts}개 콘텐츠
+
+
= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
+ ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
+ ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
+ 'bg-slate-50 text-slate-400'
+ }`}>
+ {ch.score || '-'}
+
+
+
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/performance/ui/FunnelSection.tsx b/src/features/performance/ui/FunnelSection.tsx
new file mode 100644
index 0000000..0c7f304
--- /dev/null
+++ b/src/features/performance/ui/FunnelSection.tsx
@@ -0,0 +1,69 @@
+import { motion } from 'motion/react';
+import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
+
+export function FunnelSection({ funnelMax }: { funnelMax: number }) {
+ return (
+
+
+
+
마케팅 퍼널
+
노출부터 전환까지 — 어디서 이탈하는지 파악
+
+
+
+
+ {FUNNEL_STEPS.map((step, i) => {
+ const widthPct = Math.max((step.value / funnelMax) * 100, 8);
+ const convRate = i > 0
+ ? ((step.value / FUNNEL_STEPS[i - 1].value) * 100).toFixed(1)
+ : null;
+
+ return (
+
+
+
{step.label}
+
{step.labelEn}
+
+
+
+
+ {step.display}
+
+
+
+
+ {convRate ? (
+ = 10
+ ? 'bg-[#F3F0FF] text-[#4A3A7C]'
+ : 'bg-[#FFF6ED] text-[#7C5C3A]'
+ }`}>
+ {convRate}%
+
+ ) : (
+ —
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/features/performance/ui/HeatmapSection.tsx b/src/features/performance/ui/HeatmapSection.tsx
new file mode 100644
index 0000000..a7c5603
--- /dev/null
+++ b/src/features/performance/ui/HeatmapSection.tsx
@@ -0,0 +1,65 @@
+import { motion } from 'motion/react';
+import { DAYS, TIME_SLOTS, HEATMAP_DATA, HEATMAP_INSIGHT } from '../constants/performance';
+
+function heatmapColor(value: number): string {
+ if (value >= 9) return 'bg-[#2d2640] text-white';
+ if (value >= 7) return 'bg-[#4a4460] text-white';
+ if (value >= 5) return 'bg-[#8e89a8] text-white';
+ if (value >= 3) return 'bg-[#c8c4d8] text-[#4a4460]';
+ return 'bg-[#f0eef5] text-[#8e89a8]';
+}
+
+export function HeatmapSection() {
+ return (
+
+
+
+
최적 게시 시간
+
요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음
+
+
+
낮음
+ {[1, 3, 5, 7, 9].map(v => (
+
+ ))}
+
높음
+
+
+
+
+
+
+
+ {TIME_SLOTS.map(slot => (
+
{slot}
+ ))}
+
+
+ {DAYS.map((day, di) => (
+
+ {day}
+ {HEATMAP_DATA[di].map((val, ti) => (
+
+ {val > 0 ? `${val * 10}%` : '-'}
+
+ ))}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/features/performance/ui/OverviewStatsSection.tsx b/src/features/performance/ui/OverviewStatsSection.tsx
new file mode 100644
index 0000000..5426927
--- /dev/null
+++ b/src/features/performance/ui/OverviewStatsSection.tsx
@@ -0,0 +1,22 @@
+import { motion } from 'motion/react';
+import { OVERVIEW_STATS } from '../constants/performance';
+
+export function OverviewStatsSection() {
+ return (
+
+ {OVERVIEW_STATS.map((stat, i) => (
+
+ {stat.label}
+ {stat.value}
+ {stat.delta}
+
+ ))}
+
+ );
+}
diff --git a/src/features/performance/ui/PerformanceSection.tsx b/src/features/performance/ui/PerformanceSection.tsx
new file mode 100644
index 0000000..73c1f06
--- /dev/null
+++ b/src/features/performance/ui/PerformanceSection.tsx
@@ -0,0 +1,28 @@
+import { usePerformance } from '../hooks/usePerformance';
+import { PerformanceTitle } from './PerformanceTitle';
+import { OverviewStatsSection } from './OverviewStatsSection';
+import { FunnelSection } from './FunnelSection';
+import { TrendSection } from './TrendSection';
+import { HeatmapSection } from './HeatmapSection';
+import { ChannelPerformanceSection } from './ChannelPerformanceSection';
+import { TopContentSection } from './TopContentSection';
+import { AIRecommendationSection } from './AIRecommendationSection';
+
+export function PerformanceSection() {
+ const { period, setPeriod, funnelMax, trendMax } = usePerformance();
+
+ return (
+
+ );
+}
diff --git a/src/features/performance/ui/PerformanceTitle.tsx b/src/features/performance/ui/PerformanceTitle.tsx
new file mode 100644
index 0000000..febab05
--- /dev/null
+++ b/src/features/performance/ui/PerformanceTitle.tsx
@@ -0,0 +1,35 @@
+interface PerformanceTitleProps {
+ period: '7d' | '30d' | '90d';
+ setPeriod: (p: '7d' | '30d' | '90d') => void;
+}
+
+export function PerformanceTitle({ period, setPeriod }: PerformanceTitleProps) {
+ return (
+
+
+
+
+
Performance Intelligence
+
성과 대시보드
+
모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.
+
+ {([
+ { key: '7d' as const, label: '7일' },
+ { key: '30d' as const, label: '30일' },
+ { key: '90d' as const, label: '90일' },
+ ]).map(p => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/features/performance/ui/TopContentSection.tsx b/src/features/performance/ui/TopContentSection.tsx
new file mode 100644
index 0000000..210b2d9
--- /dev/null
+++ b/src/features/performance/ui/TopContentSection.tsx
@@ -0,0 +1,65 @@
+import { motion } from 'motion/react';
+import type { ComponentType } from 'react';
+import { VideoFilled, FileTextFilled, ShareFilled } from '@/components/icons/FilledIcons';
+import { TOP_CONTENT } from '../constants/performance';
+
+const typeIcons: Record> = {
+ video: VideoFilled,
+ blog: FileTextFilled,
+ social: ShareFilled,
+};
+
+const typeColors: Record = {
+ video: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]' },
+ blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]' },
+ social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]' },
+};
+
+export function TopContentSection() {
+ return (
+
+
인기 콘텐츠 TOP 5
+
+
+ 콘텐츠
+ 채널
+ 조회수
+ 좋아요
+ 댓글
+ CTR
+ 게시일
+
+ {TOP_CONTENT.map((content, i) => {
+ const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
+ const colors = typeColors[content.type] ?? typeColors.blog;
+ return (
+
+
+
+
+
+
{content.title}
+
+ {content.channel}
+ {content.views}
+ {content.likes}
+ {content.comments}
+ = 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
+ }`}>{content.ctr}
+ {content.publishedAt}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/features/performance/ui/TrendSection.tsx b/src/features/performance/ui/TrendSection.tsx
new file mode 100644
index 0000000..3c43424
--- /dev/null
+++ b/src/features/performance/ui/TrendSection.tsx
@@ -0,0 +1,59 @@
+import { motion } from 'motion/react';
+import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
+
+export function TrendSection({ trendMax }: { trendMax: number }) {
+ return (
+
+
+
+
채널별 주간 트렌드
+
채널별 조회수 추이 비교 (단위: K)
+
+
+ {TREND_CHANNELS.map(ch => (
+
+ ))}
+
+
+
+
+ {CHANNEL_TREND.map((week, wi) => {
+ const total = week.youtube + week.instagram + week.naver + week.facebook;
+ return (
+
+
{total}K
+
+ {TREND_CHANNELS.map(ch => {
+ const val = week[ch.key];
+ const segH = (val / total) * 100;
+ return (
+
+
+ {ch.label}: {val}K
+
+
+ );
+ })}
+
+
{week.week}
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/src/features/performance/ui/index.tsx b/src/features/performance/ui/index.tsx
new file mode 100644
index 0000000..d255713
--- /dev/null
+++ b/src/features/performance/ui/index.tsx
@@ -0,0 +1,21 @@
+import { PerformanceTitle } from './PerformanceTitle';
+import { OverviewStatsSection } from './OverviewStatsSection';
+import { FunnelSection } from './FunnelSection';
+import { TrendSection } from './TrendSection';
+import { HeatmapSection } from './HeatmapSection';
+import { ChannelPerformanceSection } from './ChannelPerformanceSection';
+import { TopContentSection } from './TopContentSection';
+import { AIRecommendationSection } from './AIRecommendationSection';
+import { PerformanceSection } from './PerformanceSection';
+
+export {
+ PerformanceTitle,
+ OverviewStatsSection,
+ FunnelSection,
+ TrendSection,
+ HeatmapSection,
+ ChannelPerformanceSection,
+ TopContentSection,
+ AIRecommendationSection,
+ PerformanceSection,
+};
diff --git a/src/pages/ChannelConnect.tsx b/src/pages/ChannelConnect.tsx
new file mode 100644
index 0000000..e65f531
--- /dev/null
+++ b/src/pages/ChannelConnect.tsx
@@ -0,0 +1,9 @@
+import { ChannelConnectSection } from '@/features/channelconnect/ui';
+
+export function ChannelConnect() {
+ return (
+
+
+
+ );
+}
diff --git a/src/pages/Distribution.tsx b/src/pages/Distribution.tsx
new file mode 100644
index 0000000..14f2292
--- /dev/null
+++ b/src/pages/Distribution.tsx
@@ -0,0 +1,9 @@
+import { DistributionSection } from '@/features/distribution/ui';
+
+export function Distribution() {
+ return (
+
+
+
+ );
+}
diff --git a/src/pages/Performance.tsx b/src/pages/Performance.tsx
new file mode 100644
index 0000000..51d3458
--- /dev/null
+++ b/src/pages/Performance.tsx
@@ -0,0 +1,9 @@
+import { PerformanceSection } from '@/features/performance/ui';
+
+export function Performance() {
+ return (
+
+ );
+}