372 lines
14 KiB
TypeScript
372 lines
14 KiB
TypeScript
import { useState, useCallback } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
YoutubeFilled,
|
|
InstagramFilled,
|
|
FacebookFilled,
|
|
GlobeFilled,
|
|
TiktokFilled,
|
|
} from '../components/icons/FilledIcons';
|
|
import type { ComponentType } from 'react';
|
|
|
|
interface ChannelDef {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: ComponentType<{ size?: number; className?: string }>;
|
|
brandColor: string;
|
|
bgColor: string;
|
|
borderColor: string;
|
|
fields: { key: string; label: string; placeholder: string; type?: string }[];
|
|
}
|
|
|
|
const CHANNELS: ChannelDef[] = [
|
|
{
|
|
id: 'youtube',
|
|
name: 'YouTube',
|
|
description: '채널 연동으로 영상 자동 업로드, 성과 분석',
|
|
icon: YoutubeFilled,
|
|
brandColor: '#FF0000',
|
|
bgColor: '#FFF0F0',
|
|
borderColor: '#F5D5DC',
|
|
fields: [
|
|
{ key: 'channelUrl', label: '채널 URL', placeholder: 'https://youtube.com/@YourChannel' },
|
|
],
|
|
},
|
|
{
|
|
id: 'instagram_kr',
|
|
name: 'Instagram KR',
|
|
description: '한국 계정 — Reels, Feed, Stories 자동 게시',
|
|
icon: InstagramFilled,
|
|
brandColor: '#E1306C',
|
|
bgColor: '#FFF0F5',
|
|
borderColor: '#F5D0DC',
|
|
fields: [
|
|
{ key: 'handle', label: '핸들', placeholder: '@viewclinic_kr' },
|
|
],
|
|
},
|
|
{
|
|
id: 'instagram_en',
|
|
name: 'Instagram EN',
|
|
description: '글로벌 계정 — 해외 환자 대상 콘텐츠',
|
|
icon: InstagramFilled,
|
|
brandColor: '#E1306C',
|
|
bgColor: '#FFF0F5',
|
|
borderColor: '#F5D0DC',
|
|
fields: [
|
|
{ key: 'handle', label: '핸들', placeholder: '@viewplasticsurgery' },
|
|
],
|
|
},
|
|
{
|
|
id: 'facebook_kr',
|
|
name: 'Facebook KR',
|
|
description: '한국 페이지 — 광고 리타겟, 콘텐츠 배포',
|
|
icon: FacebookFilled,
|
|
brandColor: '#1877F2',
|
|
bgColor: '#F0F4FF',
|
|
borderColor: '#C5D5F5',
|
|
fields: [
|
|
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPage' },
|
|
],
|
|
},
|
|
{
|
|
id: 'facebook_en',
|
|
name: 'Facebook EN',
|
|
description: '글로벌 페이지 — 해외 환자 유입',
|
|
icon: FacebookFilled,
|
|
brandColor: '#1877F2',
|
|
bgColor: '#F0F4FF',
|
|
borderColor: '#C5D5F5',
|
|
fields: [
|
|
{ key: 'pageUrl', label: '페이지 URL', placeholder: 'https://facebook.com/YourPageEN' },
|
|
],
|
|
},
|
|
{
|
|
id: 'naver_blog',
|
|
name: 'Naver Blog',
|
|
description: 'SEO 블로그 포스트 자동 게시, 키워드 최적화',
|
|
icon: GlobeFilled,
|
|
brandColor: '#03C75A',
|
|
bgColor: '#F0FFF5',
|
|
borderColor: '#C5F5D5',
|
|
fields: [
|
|
{ key: 'blogUrl', label: '블로그 URL', placeholder: 'https://blog.naver.com/yourblog' },
|
|
{ key: 'apiKey', label: 'API Key', placeholder: 'Naver Open API Key', type: 'password' },
|
|
],
|
|
},
|
|
{
|
|
id: 'naver_place',
|
|
name: 'Naver Place',
|
|
description: '플레이스 정보 동기화, 리뷰 모니터링',
|
|
icon: GlobeFilled,
|
|
brandColor: '#03C75A',
|
|
bgColor: '#F0FFF5',
|
|
borderColor: '#C5F5D5',
|
|
fields: [
|
|
{ key: 'placeUrl', label: '플레이스 URL', placeholder: 'https://map.naver.com/...' },
|
|
],
|
|
},
|
|
{
|
|
id: 'tiktok',
|
|
name: 'TikTok',
|
|
description: 'Shorts 크로스포스팅, 트렌드 콘텐츠',
|
|
icon: TiktokFilled,
|
|
brandColor: '#000000',
|
|
bgColor: '#F5F5F5',
|
|
borderColor: '#E0E0E0',
|
|
fields: [
|
|
{ key: 'handle', label: '핸들', placeholder: '@yourclinic' },
|
|
],
|
|
},
|
|
{
|
|
id: 'gangnamunni',
|
|
name: '강남언니',
|
|
description: '리뷰 모니터링, 평점 추적, 시술 정보 동기화',
|
|
icon: GlobeFilled,
|
|
brandColor: '#6B2D8B',
|
|
bgColor: '#F3F0FF',
|
|
borderColor: '#D5CDF5',
|
|
fields: [
|
|
{ key: 'hospitalUrl', label: '병원 페이지 URL', placeholder: 'https://gangnamunni.com/hospitals/...' },
|
|
],
|
|
},
|
|
{
|
|
id: 'website',
|
|
name: 'Website',
|
|
description: '홈페이지 SEO 모니터링, 트래킹 픽셀 관리',
|
|
icon: GlobeFilled,
|
|
brandColor: '#6C5CE7',
|
|
bgColor: '#F3F0FF',
|
|
borderColor: '#D5CDF5',
|
|
fields: [
|
|
{ key: 'url', label: '웹사이트 URL', placeholder: 'https://www.yourclinic.com' },
|
|
{ key: 'gaId', label: 'Google Analytics ID', placeholder: 'G-XXXXXXXXXX' },
|
|
],
|
|
},
|
|
];
|
|
|
|
type ConnectionStatus = 'disconnected' | 'connecting' | 'connected';
|
|
|
|
interface ChannelState {
|
|
status: ConnectionStatus;
|
|
values: Record<string, string>;
|
|
}
|
|
|
|
export default function ChannelConnectPage() {
|
|
const navigate = useNavigate();
|
|
const [channels, setChannels] = useState<Record<string, ChannelState>>(() => {
|
|
const init: Record<string, ChannelState> = {};
|
|
for (const ch of CHANNELS) {
|
|
init[ch.id] = { status: 'disconnected', values: {} };
|
|
}
|
|
return init;
|
|
});
|
|
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const handleFieldChange = useCallback((channelId: string, fieldKey: string, value: string) => {
|
|
setChannels(prev => ({
|
|
...prev,
|
|
[channelId]: {
|
|
...prev[channelId],
|
|
values: { ...prev[channelId].values, [fieldKey]: value },
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const handleConnect = useCallback((channelId: string) => {
|
|
setChannels(prev => ({
|
|
...prev,
|
|
[channelId]: { ...prev[channelId], status: 'connecting' },
|
|
}));
|
|
// Simulate connection
|
|
setTimeout(() => {
|
|
setChannels(prev => ({
|
|
...prev,
|
|
[channelId]: { ...prev[channelId], status: 'connected' },
|
|
}));
|
|
}, 2000);
|
|
}, []);
|
|
|
|
const handleDisconnect = useCallback((channelId: string) => {
|
|
setChannels(prev => ({
|
|
...prev,
|
|
[channelId]: { status: 'disconnected', values: {} },
|
|
}));
|
|
}, []);
|
|
|
|
const connectedCount = (Object.values(channels) as ChannelState[]).filter(c => c.status === 'connected').length;
|
|
|
|
return (
|
|
<div className="pt-20 min-h-screen">
|
|
{/* Header */}
|
|
<div className="bg-gradient-to-r from-[#fff3eb] via-[#e4cfff] to-[#f5f9ff] py-16 px-6">
|
|
<div className="max-w-5xl mx-auto">
|
|
<p className="text-xs font-semibold text-[#6C5CE7] tracking-widest uppercase mb-3">Channel Integration</p>
|
|
<h1 className="font-serif text-3xl md:text-4xl font-bold text-[#021341] mb-3">
|
|
채널 연결
|
|
</h1>
|
|
<p className="text-[#021341]/60 max-w-xl">
|
|
소셜 미디어와 플랫폼을 연결하여 콘텐츠를 자동으로 배포하고 성과를 추적하세요.
|
|
</p>
|
|
|
|
{/* Connection Summary + Distribute Button */}
|
|
<div className="flex items-center gap-4 mt-8">
|
|
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/70 backdrop-blur-sm border border-white/40">
|
|
<div className={`w-3 h-3 rounded-full ${connectedCount > 0 ? 'bg-[#6C5CE7]' : 'bg-slate-300'}`} />
|
|
<span className="text-sm font-medium text-[#021341]">
|
|
{connectedCount} / {CHANNELS.length} 연결됨
|
|
</span>
|
|
</div>
|
|
{connectedCount > 0 && (
|
|
<button
|
|
onClick={() => navigate('/distribute')}
|
|
className="flex items-center gap-2 px-5 py-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-medium shadow-md hover:shadow-lg transition-all"
|
|
>
|
|
콘텐츠 배포하기
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Channel Grid */}
|
|
<div className="max-w-5xl mx-auto px-6 py-12">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{CHANNELS.map(ch => {
|
|
const state = channels[ch.id];
|
|
const isExpanded = expandedId === ch.id;
|
|
const Icon = ch.icon;
|
|
const allFieldsFilled = ch.fields.every(f => (state.values[f.key] ?? '').trim().length > 0);
|
|
|
|
return (
|
|
<motion.div
|
|
key={ch.id}
|
|
layout
|
|
className={`rounded-2xl border-2 overflow-hidden transition-all ${
|
|
state.status === 'connected'
|
|
? 'border-[#D5CDF5] bg-[#F3F0FF]/20'
|
|
: isExpanded
|
|
? 'border-[#6C5CE7] bg-white shadow-[3px_4px_12px_rgba(108,92,231,0.12)]'
|
|
: 'border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5]'
|
|
}`}
|
|
>
|
|
{/* Card Header */}
|
|
<button
|
|
onClick={() => setExpandedId(isExpanded ? null : ch.id)}
|
|
className="w-full flex items-center gap-4 p-5 text-left"
|
|
>
|
|
<div
|
|
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
|
|
style={{ backgroundColor: ch.bgColor }}
|
|
>
|
|
<Icon size={22} style={{ color: ch.brandColor }} />
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-[#0A1128]">{ch.name}</h3>
|
|
{state.status === 'connected' && (
|
|
<span className="px-2 py-1 rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs font-semibold border border-[#D5CDF5]">
|
|
연결됨
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-slate-500 mt-1 truncate">{ch.description}</p>
|
|
</div>
|
|
|
|
<svg
|
|
width="20" height="20" viewBox="0 0 20 20" fill="none"
|
|
className={`shrink-0 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
>
|
|
<path d="M5 7.5L10 12.5L15 7.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Expanded Content */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
|
|
{state.status === 'connected' ? (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-4 mt-3">
|
|
<div className="w-2 h-2 rounded-full bg-[#6C5CE7]" />
|
|
<span className="text-sm text-[#4A3A7C] font-medium">연결 상태: 활성</span>
|
|
</div>
|
|
{ch.fields.map(f => (
|
|
<div key={f.key} className="mb-2">
|
|
<span className="text-xs text-slate-400">{f.label}</span>
|
|
<p className="text-sm text-slate-700 font-medium">
|
|
{f.type === 'password' ? '••••••••' : state.values[f.key]}
|
|
</p>
|
|
</div>
|
|
))}
|
|
<button
|
|
onClick={() => handleDisconnect(ch.id)}
|
|
className="mt-4 w-full py-3 rounded-full bg-white border border-[#F5D5DC] text-[#7C3A4B] text-sm font-medium hover:bg-[#FFF0F0] transition-all"
|
|
>
|
|
연결 해제
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-3">
|
|
{ch.fields.map(f => (
|
|
<div key={f.key} className="mb-3">
|
|
<label className="text-xs font-medium text-slate-600 mb-1 block">{f.label}</label>
|
|
<input
|
|
type={f.type ?? 'text'}
|
|
value={state.values[f.key] ?? ''}
|
|
onChange={e => handleFieldChange(ch.id, f.key, e.target.value)}
|
|
placeholder={f.placeholder}
|
|
className="w-full px-4 py-3 rounded-xl border border-slate-200 text-sm text-slate-700 placeholder:text-slate-300 focus:outline-none focus:border-[#6C5CE7] focus:ring-1 focus:ring-[#6C5CE7]/20 transition-all"
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
onClick={() => handleConnect(ch.id)}
|
|
disabled={!allFieldsFilled || state.status === 'connecting'}
|
|
className="flex-1 flex items-center justify-center gap-2 py-3 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"
|
|
>
|
|
{state.status === 'connecting' ? (
|
|
<>
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
연결 중...
|
|
</>
|
|
) : (
|
|
'연결하기'
|
|
)}
|
|
</button>
|
|
<button
|
|
className="px-4 py-3 rounded-full bg-white border border-slate-200 text-slate-500 text-sm font-medium hover:bg-slate-50 transition-all"
|
|
>
|
|
OAuth
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|