"한 장씩 봐 주세요" 같은 요청문이 읽고 넘어가는 설명문과 같은 무게로 놓여 있어 지나치기 쉬웠다. 역할별 토큰과 클래스를 두고 화면에 적용한다. .ui-ask 요청문. 사람이 무엇을 해야 하는지 말하는 문장 (17px·600·인디고) .ui-ask-block 문단으로 서는 요청문. 왼쪽 규칙선으로 설명과 구분한다 .ui-body 설명 본문 (16px·slate-600) .ui-note 보조·출처 (14px·slate-400) 크기는 기존 화면을 세어 맞췄다. text-base 167회·text-sm 58회가 지배적이라 body 를 16px, note 를 14px 로 두면 치환해도 크기가 변하지 않고 역할과 색만 정리된다. 한글 줄바꿈: word-break keep-all 로 어절을 지키고, 긴 URL·영문 토큰만 overflow-wrap anywhere 로 자른다. text-wrap 으로 마지막 줄에 한 어절만 남는 것을 줄이고, 62ch 로 줄 길이를 묶는다. 제목에도 keep-all 과 balance 를 건다. 다크 섹션은 .on-dark 로 값을 뒤집고, 그 안의 흰 카드는 .on-light 로 되돌린다. 이걸 두지 않으면 흰 글씨가 흰 배경에 얹혀 보이지 않는다(디자인 시스템 기록된 실수). 사진 확인 화면의 썸네일 줄이 누를 수 있는 것으로 안 읽혀 같이 고친다. 72px 카드, 호버 반응, 순번 배지, 현재 위치 표시, 안 정한 것 아래 띠, 범례, 이전·다음 이동을 둔다. 상태가 안 보이는 것이 크기보다 큰 문제였다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
500 lines
24 KiB
TypeScript
500 lines
24 KiB
TypeScript
import { useState } from 'react';
|
||
import { motion } from 'motion/react';
|
||
import {
|
||
YoutubeFilled,
|
||
InstagramFilled,
|
||
FacebookFilled,
|
||
GlobeFilled,
|
||
TiktokFilled,
|
||
VideoFilled,
|
||
FileTextFilled,
|
||
ShareFilled,
|
||
} from '../components/icons/FilledIcons';
|
||
import type { ComponentType } from 'react';
|
||
|
||
// ─── Types ───
|
||
|
||
interface ChannelMetric {
|
||
id: string;
|
||
name: string;
|
||
icon: ComponentType<{ size?: number; className?: string }>;
|
||
brandColor: string;
|
||
bgColor: string;
|
||
followers: string;
|
||
followersDelta: string;
|
||
views: string;
|
||
viewsDelta: string;
|
||
engagement: string;
|
||
engagementDelta: string;
|
||
posts: number;
|
||
score: number;
|
||
}
|
||
|
||
interface ContentPerformance {
|
||
id: string;
|
||
title: string;
|
||
channel: string;
|
||
type: 'video' | 'blog' | 'social';
|
||
views: string;
|
||
likes: string;
|
||
comments: string;
|
||
ctr: string;
|
||
publishedAt: string;
|
||
}
|
||
|
||
// ─── Mock Data ───
|
||
|
||
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 },
|
||
];
|
||
|
||
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일 전' },
|
||
];
|
||
|
||
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 },
|
||
];
|
||
|
||
// ─── Funnel Data ───
|
||
|
||
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' },
|
||
];
|
||
|
||
// ─── Channel Trend Data (4 weeks) ───
|
||
|
||
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 },
|
||
];
|
||
|
||
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)' },
|
||
];
|
||
|
||
// ─── Heatmap Data (Day × Time Slot) ───
|
||
|
||
const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
|
||
const TIME_SLOTS = ['오전 (6-12)', '오후 (12-18)', '저녁 (18-24)', '심야 (0-6)'];
|
||
|
||
// Engagement rate by day × time slot (0-10 scale)
|
||
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], // 일
|
||
];
|
||
|
||
// ─── Component ───
|
||
|
||
const typeIcons: Record<string, ComponentType<{ size?: number; className?: string }>> = {
|
||
video: VideoFilled,
|
||
blog: FileTextFilled,
|
||
social: ShareFilled,
|
||
};
|
||
|
||
const typeColors: Record<string, { bg: string; text: string }> = {
|
||
video: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]' },
|
||
blog: { bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]' },
|
||
social: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]' },
|
||
};
|
||
|
||
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 default function PerformancePage() {
|
||
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 (
|
||
<div className="pt-20 min-h-screen">
|
||
{/* Header */}
|
||
<div className="on-dark bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
||
<div className="absolute bottom-0 left-0 w-[300px] h-[300px] rounded-full bg-purple-500/5 blur-[100px]" />
|
||
<div className="max-w-6xl mx-auto relative">
|
||
<p className="text-xs font-semibold text-purple-300 tracking-widest uppercase mb-3">Performance Intelligence</p>
|
||
<h1 className="font-serif text-3xl md:text-4xl font-bold text-white mb-3">성과 대시보드</h1>
|
||
<p className="text-purple-200/70 max-w-xl mb-8">모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.</p>
|
||
<div className="flex gap-2">
|
||
{([
|
||
{ key: '7d' as const, label: '7일' },
|
||
{ key: '30d' as const, label: '30일' },
|
||
{ key: '90d' as const, label: '90일' },
|
||
]).map(p => (
|
||
<button
|
||
key={p.key}
|
||
onClick={() => setPeriod(p.key)}
|
||
className={`px-4 py-2 rounded-full text-sm font-medium transition-all ${
|
||
period === p.key ? 'bg-white text-[#0A1128]' : 'bg-white/10 text-purple-200 hover:bg-white/20'
|
||
}`}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-6xl mx-auto px-6 py-10">
|
||
|
||
{/* Overview Stats */}
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
|
||
{OVERVIEW_STATS.map((stat, i) => (
|
||
<motion.div
|
||
key={stat.label}
|
||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
|
||
initial={{ opacity: 0, y: 15 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3, delay: i * 0.05 }}
|
||
>
|
||
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
|
||
<p className="text-xl font-bold text-[#0A1128]">{stat.value}</p>
|
||
<p className={`text-xs font-medium mt-1 ${stat.positive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'}`}>{stat.delta}</p>
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
|
||
{/* ═══ Section 1: Marketing Funnel ═══ */}
|
||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
||
<p className="text-xs text-slate-500 mt-1">노출부터 전환까지 — 어디서 이탈하는지 파악</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
{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 (
|
||
<motion.div
|
||
key={step.label}
|
||
className="flex items-center gap-4"
|
||
initial={{ opacity: 0, x: -20 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ duration: 0.4, delay: i * 0.1 }}
|
||
>
|
||
{/* Label */}
|
||
<div className="w-24 shrink-0 text-right">
|
||
<p className="text-sm font-medium text-[#0A1128]">{step.label}</p>
|
||
<p className="text-xs text-slate-400">{step.labelEn}</p>
|
||
</div>
|
||
|
||
{/* Bar */}
|
||
<div className="flex-1 relative">
|
||
<motion.div
|
||
className="h-10 rounded-xl flex items-center px-4"
|
||
style={{ backgroundColor: step.color }}
|
||
initial={{ width: 0 }}
|
||
animate={{ width: `${widthPct}%` }}
|
||
transition={{ duration: 0.7, delay: i * 0.12 }}
|
||
>
|
||
<span className="text-sm font-bold text-white whitespace-nowrap">{step.display}</span>
|
||
</motion.div>
|
||
</div>
|
||
|
||
{/* Conversion Rate */}
|
||
<div className="w-16 shrink-0 text-right">
|
||
{convRate ? (
|
||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${
|
||
parseFloat(convRate) >= 10
|
||
? 'bg-[#F3F0FF] text-[#4A3A7C]'
|
||
: 'bg-[#FFF6ED] text-[#7C5C3A]'
|
||
}`}>
|
||
{convRate}%
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-slate-300">—</span>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Funnel insight */}
|
||
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
|
||
<p className="text-sm text-[#7C5C3A]">
|
||
<span className="font-semibold">병목 구간:</span> 클릭 → 웹사이트 유입 전환율 <span className="font-bold">13.9%</span> — 랜딩 페이지 최적화가 필요합니다. 업계 평균 20% 대비 낮음.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ Section 2: Channel Trend (Stacked Bar) ═══ */}
|
||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
||
<p className="text-xs text-slate-500 mt-1">채널별 조회수 추이 비교 (단위: K)</p>
|
||
</div>
|
||
{/* Legend */}
|
||
<div className="flex gap-3">
|
||
{TREND_CHANNELS.map(ch => (
|
||
<div key={ch.key} className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ch.color }} />
|
||
<span className="text-xs text-slate-500">{ch.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-end justify-center gap-10 h-[220px] px-8">
|
||
{CHANNEL_TREND.map((week, wi) => {
|
||
const total = week.youtube + week.instagram + week.naver + week.facebook;
|
||
return (
|
||
<div key={week.week} className="flex flex-col items-center gap-2" style={{ width: '80px' }}>
|
||
{/* Total label above bar */}
|
||
<p className="text-xs text-slate-500 font-medium">{total}K</p>
|
||
{/* Stacked bar */}
|
||
<div className="w-full flex flex-col-reverse items-stretch rounded-xl overflow-hidden" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
|
||
{TREND_CHANNELS.map(ch => {
|
||
const val = week[ch.key];
|
||
const segH = (val / total) * 100;
|
||
return (
|
||
<motion.div
|
||
key={ch.key}
|
||
className="w-full relative group"
|
||
style={{ height: `${segH}%`, backgroundColor: ch.color }}
|
||
initial={{ scaleY: 0 }}
|
||
animate={{ scaleY: 1 }}
|
||
transition={{ duration: 0.5, delay: wi * 0.1 }}
|
||
>
|
||
<div className="on-dark absolute -top-8 left-1/2 -translate-x-1/2 hidden group-hover:block bg-[#0A1128] text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10">
|
||
{ch.label}: {val}K
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* Week label */}
|
||
<p className="text-xs font-medium text-slate-600">{week.week}</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Trend insight */}
|
||
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
||
<p className="text-sm text-[#4A3A7C]">
|
||
<span className="font-semibold">성장 채널:</span> Instagram 조회수 <span className="font-bold">+112%</span> (W1→W4). Naver Blog <span className="font-bold">+111%</span> 동반 성장. YouTube는 안정적 유지.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ═══ Section 3: Day × Time Heatmap ═══ */}
|
||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
||
<p className="text-xs text-slate-500 mt-1">요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음</p>
|
||
</div>
|
||
{/* Scale legend */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-slate-400">낮음</span>
|
||
{[1, 3, 5, 7, 9].map(v => (
|
||
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
|
||
))}
|
||
<span className="text-xs text-slate-400">높음</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Heatmap grid */}
|
||
<div className="overflow-x-auto">
|
||
<div className="min-w-[500px]">
|
||
{/* Time slot headers */}
|
||
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
|
||
<div />
|
||
{TIME_SLOTS.map(slot => (
|
||
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Rows */}
|
||
{DAYS.map((day, di) => (
|
||
<motion.div
|
||
key={day}
|
||
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 0.3, delay: di * 0.05 }}
|
||
>
|
||
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
|
||
{HEATMAP_DATA[di].map((val, ti) => (
|
||
<div
|
||
key={ti}
|
||
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-105 cursor-default ${heatmapColor(val)}`}
|
||
>
|
||
{val > 0 ? `${val * 10}%` : '-'}
|
||
</div>
|
||
))}
|
||
</motion.div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Heatmap insight */}
|
||
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
||
<p className="text-sm text-[#4A3A7C]">
|
||
<span className="font-semibold">최적 시간:</span> <span className="font-bold">금요일 저녁 (18-24시)</span> 참여율 최고. 평일 오후 (12-18시)가 전반적으로 높음. 주말 오전은 피하세요.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Channel Performance Grid */}
|
||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">채널별 성과</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
|
||
{CHANNELS.map((ch, i) => {
|
||
const Icon = ch.icon;
|
||
return (
|
||
<motion.div
|
||
key={ch.id}
|
||
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
||
initial={{ opacity: 0, y: 15 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
||
>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: ch.bgColor }}>
|
||
<Icon size={20} style={{ color: ch.brandColor }} />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
|
||
<p className="text-xs text-slate-400">{ch.posts}개 콘텐츠</p>
|
||
</div>
|
||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
|
||
ch.score >= 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 || '-'}
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-3">
|
||
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
|
||
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
|
||
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Top Content */}
|
||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
||
<div className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden mb-10">
|
||
<div className="on-dark grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
|
||
<span>콘텐츠</span>
|
||
<span>채널</span>
|
||
<span className="text-right">조회수</span>
|
||
<span className="text-right">좋아요</span>
|
||
<span className="text-right">댓글</span>
|
||
<span className="text-right">CTR</span>
|
||
<span className="text-right">게시일</span>
|
||
</div>
|
||
{TOP_CONTENT.map((content, i) => {
|
||
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
|
||
const colors = typeColors[content.type] ?? typeColors.blog;
|
||
return (
|
||
<motion.div
|
||
key={content.id}
|
||
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
|
||
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
|
||
} border-b border-slate-50 last:border-0`}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
||
>
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${colors.bg}`}>
|
||
<TypeIcon size={14} className={colors.text} />
|
||
</div>
|
||
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
|
||
</div>
|
||
<span className="text-xs text-slate-500">{content.channel}</span>
|
||
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
|
||
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
|
||
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
|
||
<span className={`text-sm font-medium text-right ${
|
||
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
|
||
}`}>{content.ctr}</span>
|
||
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
|
||
</motion.div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* AI Recommendations */}
|
||
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
|
||
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI 개선 추천</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{[
|
||
{ 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 버튼 위치와 페이지 속도 개선 필요.' },
|
||
].map((rec, i) => (
|
||
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
|
||
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
|
||
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MetricCell({ label, value, delta }: { label: string; value: string; delta: string }) {
|
||
const isPositive = delta.startsWith('+');
|
||
const isNew = delta === 'NEW' || delta === '-';
|
||
return (
|
||
<div className="text-center">
|
||
<p className="text-xs text-slate-400 mb-1">{label}</p>
|
||
<p className="text-sm font-semibold text-[#0A1128]">{value}</p>
|
||
<p className={`text-xs font-medium ${
|
||
isNew ? 'text-slate-400' : isPositive ? 'text-[#4A3A7C]' : 'text-[#7C3A4B]'
|
||
}`}>{delta}</p>
|
||
</div>
|
||
);
|
||
}
|