페이지 구조 수정
parent
dfc04af69f
commit
992c232e16
|
|
@ -15,6 +15,18 @@ export const CHANNELS: ChannelDef[] = [
|
|||
{ 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',
|
||||
name: 'Instagram KR',
|
||||
|
|
@ -88,18 +100,6 @@ export const CHANNELS: ChannelDef[] = [
|
|||
{ 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',
|
||||
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 type { ChannelDef, ChannelState } from '../types';
|
||||
import { CHANNELS } from '../constants/channels';
|
||||
import { useChannelConnect } from '../hooks/useChannelConnect';
|
||||
import { useChannelConnectStore } from '../store/channelConnectStore';
|
||||
import { CHANNEL_ICON_MAP } from '../utils/channelIconMap';
|
||||
import { ChannelConnectTitle } from './ChannelConnectTitle';
|
||||
|
||||
interface ChannelCardProps {
|
||||
ch: ChannelDef;
|
||||
|
|
@ -142,17 +141,15 @@ export function ChannelConnectSection() {
|
|||
const {
|
||||
channels,
|
||||
expandedId,
|
||||
connectedCount,
|
||||
setExpandedId,
|
||||
handleFieldChange,
|
||||
handleConnect,
|
||||
handleDisconnect,
|
||||
} = useChannelConnect();
|
||||
} = useChannelConnectStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ChannelConnectTitle connectedCount={connectedCount} />
|
||||
<div className="max-w-5xl mx-auto px-6 py-12">
|
||||
<section className="py-12 px-6">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{CHANNELS.map(ch => (
|
||||
<ChannelCard
|
||||
|
|
@ -168,6 +165,6 @@ export function ChannelConnectSection() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { useNavigate } from 'react-router';
|
||||
import { CHANNELS } from '../constants/channels';
|
||||
import { useChannelConnectStore } from '../store/channelConnectStore';
|
||||
|
||||
interface ChannelConnectTitleProps {
|
||||
connectedCount: number;
|
||||
}
|
||||
|
||||
export function ChannelConnectTitle({ connectedCount }: ChannelConnectTitleProps) {
|
||||
export function ChannelConnectTitle() {
|
||||
const { connectedCount } = useChannelConnectStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@ export const MOCK_CONTENT: MockContent = {
|
|||
|
||||
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: '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: '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: '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 [title, setTitle] = useState(MOCK_CONTENT.title);
|
||||
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 [scheduleDate, setScheduleDate] = useState('');
|
||||
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 type { ChannelTarget } from '../types';
|
||||
import { useDistributionStore } from '../store/distributionStore';
|
||||
import { useChannelConnectStore } from '@/features/channelconnect/store/channelConnectStore';
|
||||
|
||||
interface ChannelSelectSectionProps {
|
||||
channels: ChannelTarget[];
|
||||
toggleChannel: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectSectionProps) {
|
||||
export function ChannelSelectSection() {
|
||||
const { channels, toggleChannel } = useDistributionStore();
|
||||
const { channels: connectedChannels } = useChannelConnectStore();
|
||||
const mergedChannels = channels.map(ch => ({
|
||||
...ch,
|
||||
connected: connectedChannels[ch.id]?.status === 'connected',
|
||||
}));
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">배포 채널 선택</h3>
|
||||
<div className="space-y-3">
|
||||
{channels.map(ch => {
|
||||
{mergedChannels.map(ch => {
|
||||
const Icon = ch.icon;
|
||||
return (
|
||||
<motion.div
|
||||
|
|
@ -29,9 +31,7 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
|
|||
onClick={() => toggleChannel(ch.id)}
|
||||
disabled={!ch.connected}
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center shrink-0 transition-all ${
|
||||
ch.selected && ch.connected
|
||||
? 'border-[#6C5CE7] bg-[#6C5CE7]'
|
||||
: 'border-slate-300 bg-white'
|
||||
ch.selected && ch.connected ? 'border-[#6C5CE7] bg-[#6C5CE7]' : 'border-slate-300 bg-white'
|
||||
} disabled:cursor-not-allowed`}
|
||||
>
|
||||
{ch.selected && ch.connected && (
|
||||
|
|
@ -40,30 +40,20 @@ export function ChannelSelectSection({ channels, toggleChannel }: ChannelSelectS
|
|||
</svg>
|
||||
)}
|
||||
</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 }} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[#0A1128]">{ch.name}</span>
|
||||
{!ch.connected && (
|
||||
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">
|
||||
미연결
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-[#FFF6ED] text-[#7C5C3A] text-xs font-semibold border border-[#F5E0C5]">미연결</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">{ch.format}</p>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
{ch.status === 'publishing' && (
|
||||
<div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{ch.status === 'publishing' && <div className="w-5 h-5 border-2 border-[#6C5CE7] border-t-transparent rounded-full animate-spin" />}
|
||||
{ch.status === 'published' && (
|
||||
<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">
|
||||
|
|
@ -1,25 +1,17 @@
|
|||
import { VideoFilled } from '@/components/icons/FilledIcons';
|
||||
import { MOCK_CONTENT } from '../constants/distribution';
|
||||
import { useDistributionStore } from '../store/distributionStore';
|
||||
|
||||
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) {
|
||||
export function ContentPreviewSection() {
|
||||
const { title, setTitle, description, setDescription, tags } = useDistributionStore();
|
||||
return (
|
||||
<div className="lg:col-span-1">
|
||||
<div>
|
||||
<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">
|
||||
<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-400 mt-1">{MOCK_CONTENT.duration}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">제목</label>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">설명</label>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-2 block">태그</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]"
|
||||
>
|
||||
<span key={tag} className="px-3 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-medium border border-[#D5CDF5]">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
|
|
@ -1,63 +1,19 @@
|
|||
import { useDistribution } from '../hooks/useDistribution';
|
||||
import { DistributionTitle } from './DistributionTitle';
|
||||
import { ContentPreviewSection } from './ContentPreviewSection';
|
||||
import { ChannelSelectSection } from './ChannelSelectSection';
|
||||
import { SchedulePublishSection } from './SchedulePublishSection';
|
||||
import { ContentPreviewSection } from './ContentPreview';
|
||||
import { ChannelSelectSection } from './ChannelSelect';
|
||||
import { SchedulePublishSection } from './SchedulePublish';
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<DistributionTitle />
|
||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||
<section className="py-10 px-6">
|
||||
<div className="max-w-5xl mx-auto w-full">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<ContentPreviewSection
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
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>
|
||||
<ContentPreviewSection />
|
||||
<div className="lg:col-span-2 flex flex-col gap-8">
|
||||
<ChannelSelectSection />
|
||||
<SchedulePublishSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,26 @@
|
|||
import { motion } from 'motion/react';
|
||||
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 {
|
||||
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({
|
||||
export function SchedulePublishSection() {
|
||||
const {
|
||||
channels,
|
||||
scheduleMode, setScheduleMode,
|
||||
scheduleDate, setScheduleDate,
|
||||
scheduleHour, setScheduleHour,
|
||||
scheduleMinute, setScheduleMinute,
|
||||
schedulePeriod, setSchedulePeriod,
|
||||
isPublishing,
|
||||
selectedChannels,
|
||||
allPublished,
|
||||
handlePublish,
|
||||
}: SchedulePublishSectionProps) {
|
||||
} = useDistributionStore();
|
||||
const { channels: connectedChannels } = useChannelConnectStore();
|
||||
const mergedChannels = channels.map(ch => ({
|
||||
...ch,
|
||||
connected: connectedChannels[ch.id]?.status === 'connected',
|
||||
}));
|
||||
const selectedChannels = mergedChannels.filter(c => c.connected && c.selected);
|
||||
const allPublished = selectedChannels.length > 0 && selectedChannels.every(c => c.status === 'published');
|
||||
return (
|
||||
<div>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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-1">
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<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')}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<span className="text-xl font-bold text-slate-300">:</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
<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')}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-xl overflow-hidden border border-slate-200">
|
||||
{(['AM', 'PM'] as const).map(p => (
|
||||
<button
|
||||
|
|
@ -127,7 +102,7 @@ export function SchedulePublishSection({
|
|||
|
||||
{!allPublished ? (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
onClick={() => handlePublish(selectedChannels.map(c => c.id))}
|
||||
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"
|
||||
>
|
||||
|
|
@ -155,9 +130,7 @@ export function SchedulePublishSection({
|
|||
</svg>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-[#0A1128] mb-1">배포 완료</p>
|
||||
<p className="text-sm text-[#4A3A7C]">
|
||||
{selectedChannels.length}개 채널에 성공적으로 배포되었습니다
|
||||
</p>
|
||||
<p className="text-sm text-[#4A3A7C]">{selectedChannels.length}개 채널에 성공적으로 배포되었습니다</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,6 +2,8 @@ import { AI_RECOMMENDATIONS } from '../constants/performance';
|
|||
|
||||
export function AIRecommendationSection() {
|
||||
return (
|
||||
<section className="py-10 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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">
|
||||
|
|
@ -13,5 +15,7 @@ export function AIRecommendationSection() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ function MetricCell({ label, value, delta }: { label: string; value: string; del
|
|||
|
||||
export function ChannelPerformanceSection() {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<section className="py-10 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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">
|
||||
{CHANNELS.map((ch, i) => {
|
||||
|
|
@ -57,5 +58,6 @@ export function ChannelPerformanceSection() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { motion } from 'motion/react';
|
||||
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 (
|
||||
<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>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">마케팅 퍼널</h3>
|
||||
|
|
@ -65,5 +69,7 @@ export function FunnelSection({ funnelMax }: { funnelMax: number }) {
|
|||
<p className="text-sm text-[#7C5C3A]">{FUNNEL_INSIGHT}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ function heatmapColor(value: number): string {
|
|||
|
||||
export function HeatmapSection() {
|
||||
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>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">최적 게시 시간</h3>
|
||||
|
|
@ -47,7 +49,7 @@ export function HeatmapSection() {
|
|||
{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)}`}
|
||||
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>
|
||||
|
|
@ -61,5 +63,7 @@ export function HeatmapSection() {
|
|||
<p className="text-sm text-[#4A3A7C]">{HEATMAP_INSIGHT}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
import { motion } from 'motion/react';
|
||||
import { MetricCard } from '@/components/card/MetricCard';
|
||||
import { OVERVIEW_STATS } from '../constants/performance';
|
||||
|
||||
export function OverviewStatsSection() {
|
||||
return (
|
||||
<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
|
||||
<section className="py-10 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{OVERVIEW_STATS.map((stat) => (
|
||||
<MetricCard
|
||||
key={stat.label}
|
||||
className="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>
|
||||
label={stat.label}
|
||||
value={stat.value}
|
||||
subtext={stat.delta}
|
||||
trend={stat.positive ? 'up' : 'down'}
|
||||
/>
|
||||
))}
|
||||
</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 {
|
||||
period: '7d' | '30d' | '90d';
|
||||
setPeriod: (p: '7d' | '30d' | '90d') => void;
|
||||
}
|
||||
import { usePerformanceStore } from '../store/performanceStore';
|
||||
|
||||
export function PerformanceTitle({ period, setPeriod }: PerformanceTitleProps) {
|
||||
export function PerformanceTitle() {
|
||||
const { period, setPeriod } = usePerformanceStore();
|
||||
return (
|
||||
<div className="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">
|
||||
<div className="bg-[#0A1128] py-14 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ const typeColors: Record<string, { bg: string; text: string }> = {
|
|||
|
||||
export function TopContentSection() {
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<section className="py-10 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128] mb-4">인기 콘텐츠 TOP 5</h3>
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden">
|
||||
<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">
|
||||
|
|
@ -61,5 +62,6 @@ export function TopContentSection() {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { motion } from 'motion/react';
|
||||
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 (
|
||||
<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>
|
||||
<h3 className="font-serif font-bold text-xl text-[#0A1128]">채널별 주간 트렌드</h3>
|
||||
|
|
@ -25,14 +29,16 @@ export function TrendSection({ trendMax }: { trendMax: number }) {
|
|||
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 => {
|
||||
<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"
|
||||
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 }}
|
||||
|
|
@ -55,5 +61,7 @@ export function TrendSection({ trendMax }: { trendMax: number }) {
|
|||
<p className="text-sm text-[#4A3A7C]">{TREND_INSIGHT}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { HeatmapSection } from './HeatmapSection';
|
|||
import { ChannelPerformanceSection } from './ChannelPerformanceSection';
|
||||
import { TopContentSection } from './TopContentSection';
|
||||
import { AIRecommendationSection } from './AIRecommendationSection';
|
||||
import { PerformanceSection } from './PerformanceSection';
|
||||
|
||||
export {
|
||||
PerformanceTitle,
|
||||
|
|
@ -17,5 +16,4 @@ export {
|
|||
ChannelPerformanceSection,
|
||||
TopContentSection,
|
||||
AIRecommendationSection,
|
||||
PerformanceSection,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,18 +24,8 @@ const PAGE_FLOW: FlowStep[] = [
|
|||
{ id: "plan", label: "콘텐츠 기획", navigatePath: "/plan", isActive: (p) => p === "/plan" },
|
||||
{ id: "studio", label: "콘텐츠 제작", navigatePath: "/studio", isActive: (p) => p === "/studio" },
|
||||
{ id: "channels", label: "채널 연결", navigatePath: "/channels", isActive: (p) => p === "/channels" },
|
||||
{
|
||||
id: "distribute",
|
||||
label: "콘텐츠 배포",
|
||||
navigatePath: "/distribute",
|
||||
isActive: (p) => p === "/distribute",
|
||||
},
|
||||
{
|
||||
id: "performance",
|
||||
label: "성과 관리",
|
||||
navigatePath: "/performance",
|
||||
isActive: (p) => p === "/performance",
|
||||
},
|
||||
{ id: "distribute", label: "콘텐츠 배포", navigatePath: "/distribute", isActive: (p) => p === "/distribute" },
|
||||
{ id: "performance", label: "성과 관리", navigatePath: "/performance", isActive: (p) => p === "/performance" },
|
||||
];
|
||||
|
||||
function flowIndexForPathname(pathname: string): number {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { ChannelConnectTitle } from '@/features/channelconnect/ui';
|
||||
import { ChannelConnectSection } from '@/features/channelconnect/ui';
|
||||
|
||||
export function ChannelConnect() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<ChannelConnectTitle />
|
||||
<ChannelConnectSection />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { DistributionTitle } from '@/features/distribution/ui';
|
||||
import { DistributionSection } from '@/features/distribution/ui';
|
||||
|
||||
export function Distribution() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<DistributionTitle />
|
||||
<DistributionSection />
|
||||
</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() {
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
<PerformanceSection />
|
||||
<PerformanceTitle />
|
||||
<OverviewStatsSection />
|
||||
<FunnelSection />
|
||||
<TrendSection />
|
||||
<HeatmapSection />
|
||||
<ChannelPerformanceSection />
|
||||
<TopContentSection />
|
||||
<AIRecommendationSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue