페이지 구조 수정
parent
dfc04af69f
commit
992c232e16
|
|
@ -15,6 +15,18 @@ export const CHANNELS: ChannelDef[] = [
|
||||||
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
|
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tiktok',
|
||||||
|
name: 'TikTok',
|
||||||
|
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
|
||||||
|
iconKey: 'tiktok',
|
||||||
|
brandColor: '#000000',
|
||||||
|
bgColor: '#F5F5F5',
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
fields: [
|
||||||
|
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'instagram_kr',
|
id: 'instagram_kr',
|
||||||
name: 'Instagram KR',
|
name: 'Instagram KR',
|
||||||
|
|
@ -88,18 +100,6 @@ export const CHANNELS: ChannelDef[] = [
|
||||||
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
|
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'tiktok',
|
|
||||||
name: 'TikTok',
|
|
||||||
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
|
|
||||||
iconKey: 'tiktok',
|
|
||||||
brandColor: '#000000',
|
|
||||||
bgColor: '#F5F5F5',
|
|
||||||
borderColor: '#E0E0E0',
|
|
||||||
fields: [
|
|
||||||
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'gangnamunni',
|
id: 'gangnamunni',
|
||||||
name: '강남언니',
|
name: '강남언니',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { CHANNELS } from '../constants/channels';
|
||||||
|
import type { ChannelState } from '../types';
|
||||||
|
|
||||||
|
interface ChannelConnectStore {
|
||||||
|
channels: Record<string, ChannelState>;
|
||||||
|
expandedId: string | null;
|
||||||
|
connectedCount: number;
|
||||||
|
setExpandedId: (id: string | null) => void;
|
||||||
|
handleFieldChange: (channelId: string, fieldKey: string, value: string) => void;
|
||||||
|
handleConnect: (channelId: string) => void;
|
||||||
|
handleDisconnect: (channelId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialChannels: Record<string, ChannelState> = {};
|
||||||
|
for (const ch of CHANNELS) {
|
||||||
|
initialChannels[ch.id] = { status: 'disconnected', values: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useChannelConnectStore = create<ChannelConnectStore>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
channels: initialChannels,
|
||||||
|
expandedId: null,
|
||||||
|
connectedCount: 0,
|
||||||
|
|
||||||
|
setExpandedId: (expandedId) => set({ expandedId }),
|
||||||
|
|
||||||
|
handleFieldChange: (channelId, fieldKey, value) => set((s) => ({
|
||||||
|
channels: {
|
||||||
|
...s.channels,
|
||||||
|
[channelId]: {
|
||||||
|
...s.channels[channelId],
|
||||||
|
values: { ...s.channels[channelId].values, [fieldKey]: value },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
handleConnect: (channelId) => {
|
||||||
|
set((s) => ({
|
||||||
|
channels: { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connecting' } },
|
||||||
|
}));
|
||||||
|
setTimeout(() => {
|
||||||
|
set((s) => {
|
||||||
|
const channels = { ...s.channels, [channelId]: { ...s.channels[channelId], status: 'connected' as const } };
|
||||||
|
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
|
||||||
|
return { channels, connectedCount };
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDisconnect: (channelId) => set((s) => {
|
||||||
|
const channels = { ...s.channels, [channelId]: { status: 'disconnected' as const, values: {} } };
|
||||||
|
const connectedCount = Object.values(channels).filter(c => c.status === 'connected').length;
|
||||||
|
return { channels, connectedCount };
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ name: 'channel-connect' }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { motion, AnimatePresence } from 'motion/react';
|
import { motion, AnimatePresence } from 'motion/react';
|
||||||
import type { ChannelDef, ChannelState } from '../types';
|
import type { ChannelDef, ChannelState } from '../types';
|
||||||
import { CHANNELS } from '../constants/channels';
|
import { CHANNELS } from '../constants/channels';
|
||||||
import { useChannelConnect } from '../hooks/useChannelConnect';
|
import { useChannelConnectStore } from '../store/channelConnectStore';
|
||||||
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
|
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
|
||||||
import { ChannelConnectTitle } from './ChannelConnectTitle';
|
|
||||||
|
|
||||||
interface ChannelCardProps {
|
interface ChannelCardProps {
|
||||||
ch: ChannelDef;
|
ch: ChannelDef;
|
||||||
|
|
@ -142,17 +141,15 @@ export function ChannelConnectSection() {
|
||||||
const {
|
const {
|
||||||
channels,
|
channels,
|
||||||
expandedId,
|
expandedId,
|
||||||
connectedCount,
|
|
||||||
setExpandedId,
|
setExpandedId,
|
||||||
handleFieldChange,
|
handleFieldChange,
|
||||||
handleConnect,
|
handleConnect,
|
||||||
handleDisconnect,
|
handleDisconnect,
|
||||||
} = useChannelConnect();
|
} = useChannelConnectStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<section className="py-12 px-6">
|
||||||
<ChannelConnectTitle connectedCount={connectedCount} />
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{CHANNELS.map(ch => (
|
{CHANNELS.map(ch => (
|
||||||
<ChannelCard
|
<ChannelCard
|
||||||
|
|
@ -168,6 +165,6 @@ export function ChannelConnectSection() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { CHANNELS } from '../constants/channels';
|
import { CHANNELS } from '../constants/channels';
|
||||||
|
import { useChannelConnectStore } from '../store/channelConnectStore';
|
||||||
|
|
||||||
interface ChannelConnectTitleProps {
|
export function ChannelConnectTitle() {
|
||||||
connectedCount: number;
|
const { connectedCount } = useChannelConnectStore();
|
||||||
}
|
|
||||||
|
|
||||||
export function ChannelConnectTitle({ connectedCount }: ChannelConnectTitleProps) {
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ export const MOCK_CONTENT: MockContent = {
|
||||||
|
|
||||||
export const INITIAL_CHANNELS: ChannelTarget[] = [
|
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: '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: 'tiktok', name: 'TikTok', icon: TiktokFilled, brandColor: '#000000', bgColor: '#F5F5F5', connected: true, selected: true, status: 'ready', format: 'Short Video (9:16)' },
|
||||||
|
{ id: 'instagram_kr', name: 'Instagram KR', 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: 'facebook_kr', name: 'Facebook KR', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: true, selected: false, status: 'ready', format: 'Video Post' },
|
{ 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' },
|
{ id: 'facebook_en', name: 'Facebook EN', icon: FacebookFilled, brandColor: '#1877F2', bgColor: '#F0F4FF', connected: false, 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' },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export function useDistribution() {
|
||||||
const [channels, setChannels] = useState(INITIAL_CHANNELS);
|
const [channels, setChannels] = useState(INITIAL_CHANNELS);
|
||||||
const [title, setTitle] = useState(MOCK_CONTENT.title);
|
const [title, setTitle] = useState(MOCK_CONTENT.title);
|
||||||
const [description, setDescription] = useState(MOCK_CONTENT.description);
|
const [description, setDescription] = useState(MOCK_CONTENT.description);
|
||||||
const [tags, setTags] = useState(MOCK_CONTENT.tags);
|
const [tags] = useState(MOCK_CONTENT.tags);
|
||||||
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
|
const [scheduleMode, setScheduleMode] = useState<'now' | 'scheduled'>('now');
|
||||||
const [scheduleDate, setScheduleDate] = useState('');
|
const [scheduleDate, setScheduleDate] = useState('');
|
||||||
const [scheduleHour, setScheduleHour] = useState(9);
|
const [scheduleHour, setScheduleHour] = useState(9);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { MOCK_CONTENT, INITIAL_CHANNELS } from '../constants/distribution';
|
||||||
|
import type { ChannelTarget } from '../types';
|
||||||
|
|
||||||
|
interface DistributionStore {
|
||||||
|
channels: ChannelTarget[];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
tags: string[];
|
||||||
|
scheduleMode: 'now' | 'scheduled';
|
||||||
|
scheduleDate: string;
|
||||||
|
scheduleHour: number;
|
||||||
|
scheduleMinute: number;
|
||||||
|
schedulePeriod: 'AM' | 'PM';
|
||||||
|
isPublishing: boolean;
|
||||||
|
setTitle: (v: string) => void;
|
||||||
|
setDescription: (v: string) => void;
|
||||||
|
setScheduleMode: (v: 'now' | 'scheduled') => void;
|
||||||
|
setScheduleDate: (v: string) => void;
|
||||||
|
setScheduleHour: (fn: (h: number) => number) => void;
|
||||||
|
setScheduleMinute: (fn: (m: number) => number) => void;
|
||||||
|
setSchedulePeriod: (v: 'AM' | 'PM') => void;
|
||||||
|
toggleChannel: (id: string) => void;
|
||||||
|
handlePublish: (selectedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDistributionStore = create<DistributionStore>((set, get) => ({
|
||||||
|
channels: INITIAL_CHANNELS,
|
||||||
|
title: MOCK_CONTENT.title,
|
||||||
|
description: MOCK_CONTENT.description,
|
||||||
|
tags: MOCK_CONTENT.tags,
|
||||||
|
scheduleMode: 'now',
|
||||||
|
scheduleDate: '',
|
||||||
|
scheduleHour: 9,
|
||||||
|
scheduleMinute: 0,
|
||||||
|
schedulePeriod: 'AM',
|
||||||
|
isPublishing: false,
|
||||||
|
|
||||||
|
setTitle: (title) => set({ title }),
|
||||||
|
setDescription: (description) => set({ description }),
|
||||||
|
setScheduleMode: (scheduleMode) => set({ scheduleMode }),
|
||||||
|
setScheduleDate: (scheduleDate) => set({ scheduleDate }),
|
||||||
|
setScheduleHour: (fn) => set((s) => ({ scheduleHour: fn(s.scheduleHour) })),
|
||||||
|
setScheduleMinute: (fn) => set((s) => ({ scheduleMinute: fn(s.scheduleMinute) })),
|
||||||
|
setSchedulePeriod: (schedulePeriod) => set({ schedulePeriod }),
|
||||||
|
|
||||||
|
toggleChannel: (id) => set((s) => ({
|
||||||
|
channels: s.channels.map(c =>
|
||||||
|
c.id === id && c.connected ? { ...c, selected: !c.selected } : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
handlePublish: (selectedIds) => {
|
||||||
|
const { channels } = get();
|
||||||
|
const selected = channels.filter(c => selectedIds.includes(c.id));
|
||||||
|
set({ isPublishing: true });
|
||||||
|
|
||||||
|
selected.forEach((ch, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
set((s) => ({
|
||||||
|
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'publishing' } : c),
|
||||||
|
}));
|
||||||
|
}, i * 1500);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
set((s) => ({
|
||||||
|
channels: s.channels.map(c => c.id === ch.id ? { ...c, status: 'published' } : c),
|
||||||
|
...(i === selected.length - 1 ? { isPublishing: false } : {}),
|
||||||
|
}));
|
||||||
|
}, (i + 1) * 1500 + 500);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import type { ChannelTarget } from '../types';
|
import { useDistributionStore } from '../store/distributionStore';
|
||||||
|
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
|
||||||
|
|
||||||
interface ChannelSelectSectionProps {
|
export function ChannelSelectSection() {
|
||||||
channels: ChannelTarget[];
|
const { channels, toggleChannel } = useDistributionStore();
|
||||||
toggleChannel: (id: string) => void;
|
const { channels: connectedChannels } = useChannelConnectStore();
|
||||||
}
|
const mergedChannels = channels.map(ch => ({
|
||||||
|
...ch,
|
||||||
export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) {
|
connected: connectedChannels[ch.id]?.status === 'connected',
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 채널 선택</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 채널 선택</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{channels.map(ch => {
|
{mergedChannels.map(ch => {
|
||||||
const Icon = ch.icon;
|
const Icon = ch.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -29,9 +31,7 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
|
||||||
onClick={() => toggleChannel(ch.id)}
|
onClick={() => toggleChannel(ch.id)}
|
||||||
disabled={!ch.connected}
|
disabled={!ch.connected}
|
||||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
|
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
|
||||||
ch.selected && ch.connected
|
ch.selected && ch.connected ? 'border-[#6C5CE7] bg-[#6C5CE7]' : 'border-slate-300 bg-white'
|
||||||
? 'border-[#6C5CE7] bg-[#6C5CE7]'
|
|
||||||
: 'border-slate-300 bg-white'
|
|
||||||
} disabled:cursor-not-allowed`}
|
} disabled:cursor-not-allowed`}
|
||||||
>
|
>
|
||||||
{ch.selected && ch.connected && (
|
{ch.selected && ch.connected && (
|
||||||
|
|
@ -40,30 +40,20 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: ch.bgColor }}>
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
|
||||||
style={{ backgroundColor: ch.bgColor }}
|
|
||||||
>
|
|
||||||
<Icon size={20} style={{ color: ch.brandColor }} />
|
<Icon size={20} style={{ color: ch.brandColor }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
|
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
|
||||||
{!ch.connected && (
|
{!ch.connected && (
|
||||||
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">
|
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">미연결</span>
|
||||||
미연결
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400">{ch.format}</p>
|
<p className="text-xs text-slate-400">{ch.format}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{ch.status === 'publishing' && (
|
{ch.status === 'publishing' && <div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />}
|
||||||
<div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
{ch.status === 'published' && (
|
{ch.status === 'published' && (
|
||||||
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
|
<div className="w-6 h-6 rounded-full bg-[#6C5CE7] flex items-center justify-center">
|
||||||
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
||||||
|
|
@ -1,25 +1,17 @@
|
||||||
import { VideoFilled } from '@/components/icons/FilledIcons';
|
import { VideoFilled } from '@/components/icons/FilledIcons';
|
||||||
import { MOCK_CONTENT } from '../constants/distribution';
|
import { MOCK_CONTENT } from '../constants/distribution';
|
||||||
|
import { useDistributionStore } from '../store/distributionStore';
|
||||||
|
|
||||||
interface ContentPreviewSectionProps {
|
export function ContentPreviewSection() {
|
||||||
title: string;
|
const { title, setTitle, description, setDescription, tags } = useDistributionStore();
|
||||||
setTitle: (v: string) => void;
|
|
||||||
description: string;
|
|
||||||
setDescription: (v: string) => void;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ContentPreviewSection({ title, setTitle, description, setDescription, tags }: ContentPreviewSectionProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="lg:col-span-1">
|
<div>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">콘텐츠</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">콘텐츠</h3>
|
||||||
|
|
||||||
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] border border-slate-200 flex flex-col items-center justify-center mb-6">
|
<div className="w-full aspect-[9/16] max-w-[220px] mx-auto rounded-2xl bg-gradient-to-br from-[#F3F0FF] via-white to-[#FFF6ED] border border-slate-200 flex flex-col items-center justify-center mb-6">
|
||||||
<VideoFilled size={32} className="text-[#9B8AD4] mb-3" />
|
<VideoFilled size={32} className="text-[#9B8AD4] mb-3" />
|
||||||
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
|
<p className="text-xs text-slate-500">{MOCK_CONTENT.aspectRatio}</p>
|
||||||
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
|
<p className="text-xs text-slate-400 mt-1">{MOCK_CONTENT.duration}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -28,7 +20,6 @@ export function ContentPreviewSection({ title, setTitle, description, setDescrip
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -38,15 +29,11 @@ export function ContentPreviewSection({ title, setTitle, description, setDescrip
|
||||||
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 resize-y focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span
|
<span key={tag} className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
|
||||||
key={tag}
|
|
||||||
className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]"
|
|
||||||
>
|
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
|
@ -1,63 +1,19 @@
|
||||||
import { useDistribution } from '../hooks/useDistribution';
|
import { ContentPreviewSection } from './ContentPreview';
|
||||||
import { DistributionTitle } from './DistributionTitle';
|
import { ChannelSelectSection } from './ChannelSelect';
|
||||||
import { ContentPreviewSection } from './ContentPreviewSection';
|
import { SchedulePublishSection } from './SchedulePublish';
|
||||||
import { ChannelSelectSection } from './ChannelSelectSection';
|
|
||||||
import { SchedulePublishSection } from './SchedulePublishSection';
|
|
||||||
|
|
||||||
export function DistributionSection() {
|
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 (
|
return (
|
||||||
<div>
|
<section className="py-10 px-6">
|
||||||
<DistributionTitle />
|
<div className="max-w-5xl mx-auto w-full">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<ContentPreviewSection
|
<ContentPreviewSection />
|
||||||
title={title}
|
<div className="lg:col-span-2 flex flex-col gap-8">
|
||||||
setTitle={setTitle}
|
<ChannelSelectSection />
|
||||||
description={description}
|
<SchedulePublishSection />
|
||||||
setDescription={setDescription}
|
|
||||||
tags={tags}
|
|
||||||
/>
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<ChannelSelectSection
|
|
||||||
channels={channels}
|
|
||||||
toggleChannel={toggleChannel}
|
|
||||||
/>
|
|
||||||
<SchedulePublishSection
|
|
||||||
scheduleMode={scheduleMode}
|
|
||||||
setScheduleMode={setScheduleMode}
|
|
||||||
scheduleDate={scheduleDate}
|
|
||||||
setScheduleDate={setScheduleDate}
|
|
||||||
scheduleHour={scheduleHour}
|
|
||||||
setScheduleHour={setScheduleHour}
|
|
||||||
scheduleMinute={scheduleMinute}
|
|
||||||
setScheduleMinute={setScheduleMinute}
|
|
||||||
schedulePeriod={schedulePeriod}
|
|
||||||
setSchedulePeriod={setSchedulePeriod}
|
|
||||||
isPublishing={isPublishing}
|
|
||||||
selectedChannels={selectedChannels}
|
|
||||||
allPublished={allPublished}
|
|
||||||
handlePublish={handlePublish}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,26 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { ShareFilled } from '@/components/icons/FilledIcons';
|
import { ShareFilled } from '@/components/icons/FilledIcons';
|
||||||
import type { ChannelTarget } from '../types';
|
import { useDistributionStore } from '../store/distributionStore';
|
||||||
|
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
|
||||||
|
|
||||||
interface SchedulePublishSectionProps {
|
export function SchedulePublishSection() {
|
||||||
scheduleMode: 'now' | 'scheduled';
|
const {
|
||||||
setScheduleMode: (v: 'now' | 'scheduled') => void;
|
channels,
|
||||||
scheduleDate: string;
|
scheduleMode, setScheduleMode,
|
||||||
setScheduleDate: (v: string) => void;
|
scheduleDate, setScheduleDate,
|
||||||
scheduleHour: number;
|
scheduleHour, setScheduleHour,
|
||||||
setScheduleHour: (fn: (h: number) => number) => void;
|
scheduleMinute, setScheduleMinute,
|
||||||
scheduleMinute: number;
|
schedulePeriod, setSchedulePeriod,
|
||||||
setScheduleMinute: (fn: (m: number) => number) => void;
|
isPublishing,
|
||||||
schedulePeriod: 'AM' | 'PM';
|
handlePublish,
|
||||||
setSchedulePeriod: (v: 'AM' | 'PM') => void;
|
} = useDistributionStore();
|
||||||
isPublishing: boolean;
|
const { channels: connectedChannels } = useChannelConnectStore();
|
||||||
selectedChannels: ChannelTarget[];
|
const mergedChannels = channels.map(ch => ({
|
||||||
allPublished: boolean;
|
...ch,
|
||||||
handlePublish: () => void;
|
connected: connectedChannels[ch.id]?.status === 'connected',
|
||||||
}
|
}));
|
||||||
|
const selectedChannels = mergedChannels.filter(c => c.connected && c.selected);
|
||||||
export function SchedulePublishSection({
|
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
|
||||||
scheduleMode, setScheduleMode,
|
|
||||||
scheduleDate, setScheduleDate,
|
|
||||||
scheduleHour, setScheduleHour,
|
|
||||||
scheduleMinute, setScheduleMinute,
|
|
||||||
schedulePeriod, setSchedulePeriod,
|
|
||||||
isPublishing,
|
|
||||||
selectedChannels,
|
|
||||||
allPublished,
|
|
||||||
handlePublish,
|
|
||||||
}: SchedulePublishSectionProps) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 시간</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 시간</h3>
|
||||||
|
|
@ -63,48 +54,32 @@ export function SchedulePublishSection({
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-slate-600 mb-2 block">시간</label>
|
<label className="text-xs font-medium text-slate-600 mb-2 block">시간</label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
|
||||||
onClick={() => setScheduleHour(h => h <= 1 ? 12 : h - 1)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
|
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
|
||||||
{String(scheduleHour).padStart(2, '0')}
|
{String(scheduleHour).padStart(2, '0')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
|
||||||
onClick={() => setScheduleHour(h => h >= 12 ? 1 : h + 1)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-xl font-bold text-slate-300">:</span>
|
<span className="text-xl font-bold text-slate-300">:</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
|
||||||
onClick={() => setScheduleMinute(m => m <= 0 ? 55 : m - 5)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2 6h8M6 2l-4 4 4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</button>
|
</button>
|
||||||
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
|
<div className="w-12 h-10 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] flex items-center justify-center text-lg font-semibold text-[#0A1128]">
|
||||||
{String(scheduleMinute).padStart(2, '0')}
|
{String(scheduleMinute).padStart(2, '0')}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)} className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all">
|
||||||
onClick={() => setScheduleMinute(m => m >= 55 ? 0 : m + 5)}
|
|
||||||
className="w-8 h-8 rounded-lg bg-slate-50 border border-slate-200 flex items-center justify-center text-slate-500 hover:bg-slate-100 transition-all"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M10 6H2M6 10l4-4-4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
||||||
{(['AM', 'PM'] as const).map(p => (
|
{(['AM', 'PM'] as const).map(p => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -127,7 +102,7 @@ export function SchedulePublishSection({
|
||||||
|
|
||||||
{!allPublished ? (
|
{!allPublished ? (
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={() => handlePublish(selectedChannels.map(c => c.id))}
|
||||||
disabled={selectedChannels.length === 0 || isPublishing}
|
disabled={selectedChannels.length === 0 || isPublishing}
|
||||||
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
className="w-full flex items-center justify-center gap-2 py-4 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
|
@ -155,9 +130,7 @@ export function SchedulePublishSection({
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-semibold text-[#0A1128] mb-1">배포 완료</p>
|
<p className="text-lg font-semibold text-[#0A1128] mb-1">배포 완료</p>
|
||||||
<p className="text-sm text-[#4A3A7C]">
|
<p className="text-sm text-[#4A3A7C]">{selectedChannels.length}개 채널에 성공적으로 배포되었습니다</p>
|
||||||
{selectedChannels.length}개 채널에 성공적으로 배포되었습니다
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
import { DistributionTitle } from './DistributionTitle';
|
import { DistributionTitle } from './DistributionTitle';
|
||||||
import { ContentPreviewSection } from './ContentPreviewSection';
|
|
||||||
import { ChannelSelectSection } from './ChannelSelectSection';
|
|
||||||
import { SchedulePublishSection } from './SchedulePublishSection';
|
|
||||||
import { DistributionSection } from './DistributionSection';
|
import { DistributionSection } from './DistributionSection';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DistributionTitle,
|
DistributionTitle,
|
||||||
ContentPreviewSection,
|
|
||||||
ChannelSelectSection,
|
|
||||||
SchedulePublishSection,
|
|
||||||
DistributionSection,
|
DistributionSection,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { FUNNEL_STEPS, CHANNEL_TREND } from '../constants/performance';
|
||||||
|
|
||||||
|
interface PerformanceStore {
|
||||||
|
period: '7d' | '30d' | '90d';
|
||||||
|
setPeriod: (p: '7d' | '30d' | '90d') => void;
|
||||||
|
funnelMax: number;
|
||||||
|
trendMax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePerformanceStore = create<PerformanceStore>((set) => ({
|
||||||
|
period: '30d',
|
||||||
|
setPeriod: (period) => set({ period }),
|
||||||
|
funnelMax: FUNNEL_STEPS[0].value,
|
||||||
|
trendMax: Math.max(...CHANNEL_TREND.flatMap(w => [w.youtube, w.instagram, w.naver, w.facebook])),
|
||||||
|
}));
|
||||||
|
|
@ -2,16 +2,20 @@ import { AI_RECOMMENDATIONS } from '../constants/performance';
|
||||||
|
|
||||||
export function AIRecommendationSection() {
|
export function AIRecommendationSection() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
|
<section className="py-10 px-6">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI 개선 추천</h3>
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] rounded-2xl p-8">
|
||||||
{AI_RECOMMENDATIONS.map((rec, i) => (
|
<h3 className="font-serif font-bold text-xl text-[#021341] mb-4">AI 개선 추천</h3>
|
||||||
<div key={i} className="bg-white/70 backdrop-blur-sm rounded-xl border border-white/40 p-5">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<h4 className="font-semibold text-[#021341] mb-2">{rec.title}</h4>
|
{AI_RECOMMENDATIONS.map((rec, i) => (
|
||||||
<p className="text-sm text-[#021341]/60">{rec.desc}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,45 +17,47 @@ function MetricCell({ label, value, delta }: { label: string; value: string; del
|
||||||
|
|
||||||
export function ChannelPerformanceSection() {
|
export function ChannelPerformanceSection() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<section className="py-10 px-6">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">채널별 성과</h3>
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">채널별 성과</h3>
|
||||||
{CHANNELS.map((ch, i) => {
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
const Icon = ch.icon;
|
{CHANNELS.map((ch, i) => {
|
||||||
return (
|
const Icon = ch.icon;
|
||||||
<motion.div
|
return (
|
||||||
key={ch.id}
|
<motion.div
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
key={ch.id}
|
||||||
initial={{ opacity: 0, y: 15 }}
|
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-5"
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial={{ opacity: 0, y: 15 }}
|
||||||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
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 }}>
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Icon size={20} style={{ color: ch.brandColor }} />
|
<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>
|
||||||
<div className="flex-1">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<h4 className="text-sm font-semibold text-[#0A1128]">{ch.name}</h4>
|
<MetricCell label="팔로워" value={ch.followers} delta={ch.followersDelta} />
|
||||||
<p className="text-xs text-slate-400">{ch.posts}개 콘텐츠</p>
|
<MetricCell label="조회수" value={ch.views} delta={ch.viewsDelta} />
|
||||||
|
<MetricCell label="참여율" value={ch.engagement} delta={ch.engagementDelta} />
|
||||||
</div>
|
</div>
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold ${
|
</motion.div>
|
||||||
ch.score >= 70 ? 'bg-[#F3F0FF] text-[#4A3A7C]' :
|
);
|
||||||
ch.score >= 40 ? 'bg-[#FFF6ED] text-[#7C5C3A]' :
|
})}
|
||||||
ch.score > 0 ? 'bg-[#FFF0F0] text-[#7C3A4B]' :
|
</div>
|
||||||
'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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
|
import { FUNNEL_STEPS, FUNNEL_INSIGHT } from '../constants/performance';
|
||||||
|
import { usePerformanceStore } from '../store/performanceStore';
|
||||||
|
|
||||||
export function FunnelSection({ funnelMax }: { funnelMax: number }) {
|
export function FunnelSection() {
|
||||||
|
const { funnelMax } = usePerformanceStore();
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
<section className="py-10 px-6">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
||||||
|
|
@ -64,6 +68,8 @@ export function FunnelSection({ funnelMax }: { funnelMax: number }) {
|
||||||
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
|
<div className="mt-6 p-4 rounded-xl bg-[#FFF6ED] border border-[#F5E0C5]">
|
||||||
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
|
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,55 +11,59 @@ function heatmapColor(value: number): string {
|
||||||
|
|
||||||
export function HeatmapSection() {
|
export function HeatmapSection() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
<section className="py-10 px-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div>
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-xs text-slate-500 mt-1">요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음</p>
|
<div>
|
||||||
</div>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
||||||
<div className="flex items-center gap-1">
|
<p className="text-xs text-slate-500 mt-1">요일×시간대별 참여율 히트맵 — 진할수록 성과가 높음</p>
|
||||||
<span className="text-xs text-slate-400">낮음</span>
|
</div>
|
||||||
{[1, 3, 5, 7, 9].map(v => (
|
<div className="flex items-center gap-1">
|
||||||
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
|
<span className="text-xs text-slate-400">낮음</span>
|
||||||
))}
|
{[1, 3, 5, 7, 9].map(v => (
|
||||||
<span className="text-xs text-slate-400">높음</span>
|
<div key={v} className={`w-4 h-4 rounded ${heatmapColor(v)}`} />
|
||||||
</div>
|
))}
|
||||||
</div>
|
<span className="text-xs text-slate-400">높음</span>
|
||||||
|
</div>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<div className="min-w-[500px]">
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{DAYS.map((day, di) => (
|
<div className="overflow-x-auto">
|
||||||
<motion.div
|
<div className="min-w-[500px]">
|
||||||
key={day}
|
<div className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2">
|
||||||
className="grid grid-cols-[60px_repeat(4,1fr)] gap-2 mb-2"
|
<div />
|
||||||
initial={{ opacity: 0 }}
|
{TIME_SLOTS.map(slot => (
|
||||||
animate={{ opacity: 1 }}
|
<div key={slot} className="text-center text-xs text-slate-500 font-medium">{slot}</div>
|
||||||
transition={{ duration: 0.3, delay: di * 0.05 }}
|
))}
|
||||||
>
|
</div>
|
||||||
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
|
|
||||||
{HEATMAP_DATA[di].map((val, ti) => (
|
{DAYS.map((day, di) => (
|
||||||
<div
|
<motion.div
|
||||||
key={ti}
|
key={day}
|
||||||
className={`h-12 rounded-xl flex items-center justify-center text-sm font-semibold transition-all hover:scale-105 cursor-default ${heatmapColor(val)}`}
|
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 }}
|
||||||
>
|
>
|
||||||
{val > 0 ? `${val * 10}%` : '-'}
|
<div className="flex items-center justify-center text-sm font-medium text-[#0A1128]">{day}</div>
|
||||||
</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-95 cursor-default ${heatmapColor(val)}`}
|
||||||
|
>
|
||||||
|
{val > 0 ? `${val * 10}%` : '-'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</motion.div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
||||||
|
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
|
||||||
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
import { motion } from 'motion/react';
|
import { MetricCard } from '@/components/card/MetricCard';
|
||||||
import { OVERVIEW_STATS } from '../constants/performance';
|
import { OVERVIEW_STATS } from '../constants/performance';
|
||||||
|
|
||||||
export function OverviewStatsSection() {
|
export function OverviewStatsSection() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-10">
|
<section className="py-10 px-6">
|
||||||
{OVERVIEW_STATS.map((stat, i) => (
|
<div className="max-w-6xl mx-auto">
|
||||||
<motion.div
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
key={stat.label}
|
{OVERVIEW_STATS.map((stat) => (
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-4"
|
<MetricCard
|
||||||
initial={{ opacity: 0, y: 15 }}
|
key={stat.label}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
label={stat.label}
|
||||||
transition={{ duration: 0.3, delay: i * 0.05 }}
|
value={stat.value}
|
||||||
>
|
subtext={stat.delta}
|
||||||
<p className="text-xs text-slate-500 mb-1">{stat.label}</p>
|
trend={stat.positive ? 'up' : 'down'}
|
||||||
<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>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<PerformanceTitle period={period} setPeriod={setPeriod} />
|
|
||||||
<div className="max-w-6xl mx-auto px-6 py-10">
|
|
||||||
<OverviewStatsSection />
|
|
||||||
<FunnelSection funnelMax={funnelMax} />
|
|
||||||
<TrendSection trendMax={trendMax} />
|
|
||||||
<HeatmapSection />
|
|
||||||
<ChannelPerformanceSection />
|
|
||||||
<TopContentSection />
|
|
||||||
<AIRecommendationSection />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
interface PerformanceTitleProps {
|
import { usePerformanceStore } from '../store/performanceStore';
|
||||||
period: '7d' | '30d' | '90d';
|
|
||||||
setPeriod: (p: '7d' | '30d' | '90d') => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PerformanceTitle({ period, setPeriod }: PerformanceTitleProps) {
|
export function PerformanceTitle() {
|
||||||
|
const { period, setPeriod } = usePerformanceStore();
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#0A1128] py-14 px-6 relative overflow-hidden">
|
<div className="bg-[#0A1128] py-14 px-6">
|
||||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-[#6C5CE7]/10 blur-[120px]" />
|
<div className="max-w-6xl mx-auto">
|
||||||
<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>
|
<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>
|
<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>
|
<p className="text-purple-200/70 max-w-xl mb-8">모든 채널의 마케팅 성과를 실시간으로 모니터링합니다.</p>
|
||||||
|
|
|
||||||
|
|
@ -17,49 +17,51 @@ const typeColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
|
||||||
export function TopContentSection() {
|
export function TopContentSection() {
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<section className="py-10 px-6">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
<div className="max-w-6xl mx-auto">
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
|
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
||||||
<div className="grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-3 bg-[#0A1128] text-white text-xs font-medium">
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
|
||||||
<span>콘텐츠</span>
|
<div className="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>채널</span>
|
||||||
<span className="text-right">좋아요</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>
|
||||||
<span className="text-right">게시일</span>
|
<span className="text-right">CTR</span>
|
||||||
</div>
|
<span className="text-right">게시일</span>
|
||||||
{TOP_CONTENT.map((content, i) => {
|
</div>
|
||||||
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
|
{TOP_CONTENT.map((content, i) => {
|
||||||
const colors = typeColors[content.type] ?? typeColors.blog;
|
const TypeIcon = typeIcons[content.type] ?? FileTextFilled;
|
||||||
return (
|
const colors = typeColors[content.type] ?? typeColors.blog;
|
||||||
<motion.div
|
return (
|
||||||
key={content.id}
|
<motion.div
|
||||||
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
|
key={content.id}
|
||||||
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
|
className={`grid grid-cols-[1fr_100px_80px_80px_60px_70px_70px] gap-2 px-5 py-4 items-center ${
|
||||||
} border-b border-slate-50 last:border-0`}
|
i % 2 === 0 ? 'bg-white' : 'bg-slate-50/50'
|
||||||
initial={{ opacity: 0 }}
|
} border-b border-slate-50 last:border-0`}
|
||||||
animate={{ opacity: 1 }}
|
initial={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.3, delay: i * 0.08 }}
|
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}`}>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<TypeIcon size={14} className={colors.text} />
|
<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>
|
</div>
|
||||||
<span className="text-sm text-[#0A1128] truncate">{content.title}</span>
|
<span className="text-xs text-slate-500">{content.channel}</span>
|
||||||
</div>
|
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
|
||||||
<span className="text-xs text-slate-500">{content.channel}</span>
|
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
|
||||||
<span className="text-sm font-medium text-[#0A1128] text-right">{content.views}</span>
|
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
|
||||||
<span className="text-sm text-slate-600 text-right">{content.likes}</span>
|
<span className={`text-sm font-medium text-right ${
|
||||||
<span className="text-sm text-slate-600 text-right">{content.comments}</span>
|
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
|
||||||
<span className={`text-sm font-medium text-right ${
|
}`}>{content.ctr}</span>
|
||||||
parseFloat(content.ctr) >= 10 ? 'text-[#4A3A7C]' : 'text-slate-600'
|
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
|
||||||
}`}>{content.ctr}</span>
|
</motion.div>
|
||||||
<span className="text-xs text-slate-400 text-right">{content.publishedAt}</span>
|
);
|
||||||
</motion.div>
|
})}
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,67 @@
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
|
import { CHANNEL_TREND, TREND_CHANNELS, TREND_INSIGHT } from '../constants/performance';
|
||||||
|
import { usePerformanceStore } from '../store/performanceStore';
|
||||||
|
|
||||||
export function TrendSection({ trendMax }: { trendMax: number }) {
|
export function TrendSection() {
|
||||||
|
const { trendMax } = usePerformanceStore();
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 mb-10">
|
<section className="py-10 px-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="max-w-6xl mx-auto">
|
||||||
<div>
|
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6">
|
||||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-xs text-slate-500 mt-1">채널별 조회수 추이 비교 (단위: K)</p>
|
<div>
|
||||||
</div>
|
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
||||||
<div className="flex gap-3">
|
<p className="text-xs text-slate-500 mt-1">채널별 조회수 추이 비교 (단위: K)</p>
|
||||||
{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 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' }}>
|
||||||
|
<p className="text-xs text-slate-500 font-medium">{total}K</p>
|
||||||
|
<div className="w-full flex flex-col-reverse items-stretch" style={{ height: `${(total / (trendMax * 1.5)) * 160}px` }}>
|
||||||
|
{TREND_CHANNELS.map((ch, ci) => {
|
||||||
|
const val = week[ch.key];
|
||||||
|
const segH = (val / total) * 100;
|
||||||
|
const isFirst = ci === 0;
|
||||||
|
const isLast = ci === TREND_CHANNELS.length - 1;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={ch.key}
|
||||||
|
className={`w-full relative group ${isFirst ? 'rounded-b-xl' : ''} ${isLast ? 'rounded-t-xl' : ''}`}
|
||||||
|
style={{ height: `${segH}%`, backgroundColor: ch.color }}
|
||||||
|
initial={{ scaleY: 0 }}
|
||||||
|
animate={{ scaleY: 1 }}
|
||||||
|
transition={{ duration: 0.5, delay: wi * 0.1 }}
|
||||||
|
>
|
||||||
|
<div className="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>
|
||||||
|
<p className="text-xs font-medium text-slate-600">{week.week}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
||||||
|
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<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' }}>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">{total}K</p>
|
|
||||||
<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="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>
|
|
||||||
<p className="text-xs font-medium text-slate-600">{week.week}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
|
|
||||||
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { HeatmapSection } from './HeatmapSection';
|
||||||
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
|
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
|
||||||
import { TopContentSection } from './TopContentSection';
|
import { TopContentSection } from './TopContentSection';
|
||||||
import { AIRecommendationSection } from './AIRecommendationSection';
|
import { AIRecommendationSection } from './AIRecommendationSection';
|
||||||
import { PerformanceSection } from './PerformanceSection';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PerformanceTitle,
|
PerformanceTitle,
|
||||||
|
|
@ -17,5 +16,4 @@ export {
|
||||||
ChannelPerformanceSection,
|
ChannelPerformanceSection,
|
||||||
TopContentSection,
|
TopContentSection,
|
||||||
AIRecommendationSection,
|
AIRecommendationSection,
|
||||||
PerformanceSection,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,8 @@ const PAGE_FLOW: FlowStep[] = [
|
||||||
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
|
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
|
||||||
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
||||||
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
||||||
{
|
{ id: "distribute", label: "콘텐츠 배포", navigatePath: "/distribute", isActive: (p) => p === "/distribute" },
|
||||||
id: "distribute",
|
{ id: "performance", label: "성과 관리", navigatePath: "/performance", isActive: (p) => p === "/performance" },
|
||||||
label: "콘텐츠 배포",
|
|
||||||
navigatePath: "/distribute",
|
|
||||||
isActive: (p) => p === "/distribute",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "performance",
|
|
||||||
label: "성과 관리",
|
|
||||||
navigatePath: "/performance",
|
|
||||||
isActive: (p) => p === "/performance",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function flowIndexForPathname(pathname: string): number {
|
function flowIndexForPathname(pathname: string): number {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { ChannelConnectTitle } from '@/features/channelconnect/ui';
|
||||||
import { ChannelConnectSection } from '@/features/channelconnect/ui';
|
import { ChannelConnectSection } from '@/features/channelconnect/ui';
|
||||||
|
|
||||||
export function ChannelConnect() {
|
export function ChannelConnect() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
|
<ChannelConnectTitle />
|
||||||
<ChannelConnectSection />
|
<ChannelConnectSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { DistributionTitle } from '@/features/distribution/ui';
|
||||||
import { DistributionSection } from '@/features/distribution/ui';
|
import { DistributionSection } from '@/features/distribution/ui';
|
||||||
|
|
||||||
export function Distribution() {
|
export function Distribution() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
|
<DistributionTitle />
|
||||||
<DistributionSection />
|
<DistributionSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import { PerformanceSection } from '@/features/performance/ui';
|
import { PerformanceTitle } from '@/features/performance/ui';
|
||||||
|
import { OverviewStatsSection } from '@/features/performance/ui';
|
||||||
|
import { FunnelSection } from '@/features/performance/ui';
|
||||||
|
import { TrendSection } from '@/features/performance/ui';
|
||||||
|
import { HeatmapSection } from '@/features/performance/ui';
|
||||||
|
import { ChannelPerformanceSection } from '@/features/performance/ui';
|
||||||
|
import { TopContentSection } from '@/features/performance/ui';
|
||||||
|
import { AIRecommendationSection } from '@/features/performance/ui';
|
||||||
|
|
||||||
export function Performance() {
|
export function Performance() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<PerformanceSection />
|
<PerformanceTitle />
|
||||||
|
<OverviewStatsSection />
|
||||||
|
<FunnelSection />
|
||||||
|
<TrendSection />
|
||||||
|
<HeatmapSection />
|
||||||
|
<ChannelPerformanceSection />
|
||||||
|
<TopContentSection />
|
||||||
|
<AIRecommendationSection />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue