o2o-infinith-demo/src/pages/ChannelConnectPage.tsx

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>
);
}