forked from sagnik/Project_Velocity
1009 lines
50 KiB
TypeScript
1009 lines
50 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import {
|
|
Megaphone, Clapperboard, BarChart3, Globe, Settings2,
|
|
Zap, TrendingUp, Eye, MousePointerClick, DollarSign,
|
|
Upload, Play, Image, Film, RefreshCw, ArrowRight, Plus, X,
|
|
AlertTriangle, ArrowRightLeft, PlusCircle, SlidersHorizontal,
|
|
Activity, Check, Link2, WandSparkles,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import {
|
|
AreaChart, Area, BarChart, Bar,
|
|
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
|
} from 'recharts';
|
|
import { useMarketingStore } from '@/store/useMarketingStore';
|
|
import { useCurrency } from '@/store/useCurrencyStore';
|
|
import type { Campaign, MarketingAsset, LiveOptimizationEvent, LiveEventType } from '@/types';
|
|
import { GroundTruthPicker } from './GroundTruthPicker';
|
|
import { CatalystMarketingTab } from './CatalystMarketingTab';
|
|
import { CatalystDreamWeaverTab } from './CatalystDreamWeaverTab';
|
|
import type { GroundTruthSelection } from './GroundTruthPicker';
|
|
|
|
// ── Design tokens ─────────────────────────────────────────────────────────────
|
|
const GLASS = {
|
|
background: 'rgba(8, 10, 18, 0.82)',
|
|
border: '1px solid rgba(59,130,246,0.14)',
|
|
backdropFilter: 'blur(24px)',
|
|
WebkitBackdropFilter: 'blur(24px)',
|
|
boxShadow: '0 0 0 1px rgba(255,255,255,0.04), 0 4px 32px rgba(0,0,0,0.55)',
|
|
} as const;
|
|
|
|
const INNER = {
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: '1px solid rgba(255,255,255,0.07)',
|
|
} as const;
|
|
|
|
// ── Shared primitives ─────────────────────────────────────────────────────────
|
|
|
|
function Widget({ children, className = '', delay = 0, colSpan = 1, rowSpan = 1, style }: {
|
|
children: React.ReactNode; className?: string; delay?: number; colSpan?: number; rowSpan?: number; style?: React.CSSProperties;
|
|
}) {
|
|
return (
|
|
<motion.div
|
|
className={`relative rounded-2xl p-5 overflow-hidden ${className}`}
|
|
style={{ gridColumn: `span ${colSpan}`, gridRow: `span ${rowSpan}`, ...GLASS, ...style }}
|
|
initial={{ opacity: 0, y: 16, scale: 0.97 }}
|
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
transition={{ duration: 0.35, delay, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
<div className="absolute inset-x-0 top-0 h-px pointer-events-none"
|
|
style={{ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.10), transparent)' }} />
|
|
{children}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function GhostButton({ children, onClick, danger = false, className = '' }: {
|
|
children: React.ReactNode; onClick?: () => void; danger?: boolean; className?: string;
|
|
}) {
|
|
return (
|
|
<motion.button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${className}`}
|
|
style={danger
|
|
? { background: 'rgba(239,68,68,0.12)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }
|
|
: { ...INNER, color: 'rgba(255,255,255,0.8)' }
|
|
}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
{children}
|
|
</motion.button>
|
|
);
|
|
}
|
|
|
|
function DarkInput({ value, onChange, placeholder, type = 'text', readOnly = false }: {
|
|
value: string; onChange?: (v: string) => void; placeholder?: string; type?: string; readOnly?: boolean;
|
|
}) {
|
|
return (
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
readOnly={readOnly}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
className="rounded-xl px-3 py-2 text-sm text-white w-full focus:outline-none transition-all"
|
|
style={{ ...INNER, caretColor: 'hsl(var(--accent))' }}
|
|
onFocus={(e) => { if (!readOnly) e.currentTarget.style.border = '1px solid rgba(59,130,246,0.4)'; }}
|
|
onBlur={(e) => { e.currentTarget.style.border = '1px solid rgba(255,255,255,0.07)'; }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function LiveBadge() {
|
|
return (
|
|
<div className="flex items-center gap-1.5 text-xs font-medium" style={{ color: '#4ade80' }}>
|
|
<motion.div className="w-1.5 h-1.5 rounded-full bg-green-400"
|
|
animate={{ opacity: [1, 0.3, 1] }} transition={{ repeat: Infinity, duration: 1.6 }} />
|
|
Live
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── KPI Stat card ─────────────────────────────────────────────────────────────
|
|
function StatCard({ icon: Icon, label, value, sub, glowColor = 'rgba(59,130,246,0.2)', delay = 0 }: {
|
|
icon: LucideIcon; label: string; value: string; sub: string; glowColor?: string; delay?: number;
|
|
}) {
|
|
return (
|
|
<Widget delay={delay} className="flex flex-col justify-between min-h-[130px]">
|
|
<div className="absolute -bottom-6 -right-6 w-44 h-44 pointer-events-none"
|
|
style={{ background: `radial-gradient(ellipse at 70% 80%, ${glowColor} 0%, transparent 65%)`, filter: 'blur(18px)' }} />
|
|
<div className="flex items-center justify-between">
|
|
<div className="w-8 h-8 rounded-xl flex items-center justify-center"
|
|
style={{ background: `${glowColor.replace('0.2', '0.12')}`, border: `1px solid ${glowColor.replace('0.2', '0.25')}` }}>
|
|
<Icon className="w-4 h-4" style={{ color: glowColor.includes('59,130,246') ? '#60a5fa' : glowColor.includes('34,211,238') ? '#22d3ee' : glowColor.includes('251,191,36') ? '#fbbf24' : '#a78bfa' }} />
|
|
</div>
|
|
<LiveBadge />
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-widest mb-1" style={{ color: 'rgba(148,163,184,0.65)' }}>{label}</p>
|
|
<motion.p className="text-3xl font-semibold text-white leading-none"
|
|
initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.4, delay: delay + 0.12, type: 'spring', stiffness: 200 }}>
|
|
{value}
|
|
</motion.p>
|
|
<p className="text-xs mt-1" style={{ color: 'rgba(148,163,184,0.5)' }}>{sub}</p>
|
|
</div>
|
|
</Widget>
|
|
);
|
|
}
|
|
|
|
// ── Campaign status badge ─────────────────────────────────────────────────────
|
|
function CampaignStatusBadge({ status }: { status: Campaign['status'] }) {
|
|
const map: Record<Campaign['status'], { label: string; color: string; bg: string }> = {
|
|
ACTIVE: { label: 'Active', color: '#4ade80', bg: 'rgba(74,222,128,0.12)' },
|
|
PAUSED: { label: 'Paused', color: '#fbbf24', bg: 'rgba(251,191,36,0.12)' },
|
|
DELETED: { label: 'Deleted', color: '#f87171', bg: 'rgba(248,113,113,0.12)' },
|
|
ARCHIVED: { label: 'Archived', color: '#94a3b8', bg: 'rgba(148,163,184,0.12)' },
|
|
};
|
|
const { label, color, bg } = map[status];
|
|
return (
|
|
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium" style={{ color, background: bg }}>{label}</span>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// TAB 1 — The Studio
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const RENDER_MESSAGES: Record<string, string[]> = {
|
|
rendering: [
|
|
'Wan 2.2 is rendering cinematic lighting...',
|
|
'Wan 2.2 is compositing the infinity pool reflection...',
|
|
'Qwen-Image 2512 is synthesizing Arabic typography...',
|
|
'Flux-Schnell is upscaling to 4K resolution...',
|
|
],
|
|
queued: [
|
|
'Qwen-Image 2512 queued for cinematic poster render...',
|
|
'Wan 2.2 14B queued — GPU resources freeing up...',
|
|
],
|
|
};
|
|
|
|
function AssetCard({ asset }: { asset: MarketingAsset }) {
|
|
const isProcessing = asset.status === 'rendering' || asset.status === 'queued';
|
|
const [msgIdx, setMsgIdx] = useState(0);
|
|
|
|
useEffect(() => {
|
|
if (!isProcessing) return;
|
|
const msgs = RENDER_MESSAGES[asset.status] ?? [];
|
|
if (msgs.length === 0) return;
|
|
const t = setInterval(() => setMsgIdx((i) => (i + 1) % msgs.length), 2800);
|
|
return () => clearInterval(t);
|
|
}, [isProcessing, asset.status]);
|
|
|
|
const statusColor: Record<typeof asset.status, string> = {
|
|
queued: 'rgba(148,163,184,0.8)',
|
|
rendering: '#fbbf24',
|
|
ready: '#4ade80',
|
|
uploaded: '#60a5fa',
|
|
failed: '#f87171',
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
className="relative rounded-2xl overflow-hidden flex flex-col"
|
|
style={{ ...INNER, minHeight: 200, border: `1px solid ${statusColor[asset.status]}22` }}
|
|
whileHover={{ scale: 1.015 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{/* Thumbnail / shimmer area */}
|
|
<div className="relative flex-1 flex items-center justify-center min-h-[120px]"
|
|
style={{ background: 'rgba(255,255,255,0.02)' }}>
|
|
{isProcessing ? (
|
|
<div className="w-full h-full absolute inset-0">
|
|
<motion.div className="absolute inset-0"
|
|
style={{ background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)' }}
|
|
animate={{ x: ['-100%', '200%'] }}
|
|
transition={{ repeat: Infinity, duration: 1.6, ease: 'linear' }} />
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-3 text-center">
|
|
{asset.type === 'video'
|
|
? <Film className="w-8 h-8 text-amber-400 opacity-60" />
|
|
: <Image className="w-8 h-8 text-blue-400 opacity-60" />}
|
|
<motion.p className="text-xs font-medium leading-relaxed"
|
|
style={{ color: statusColor[asset.status] }}
|
|
key={msgIdx}
|
|
initial={{ opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4 }}>
|
|
{(RENDER_MESSAGES[asset.status] ?? [])[msgIdx] ?? asset.renderMessage}
|
|
</motion.p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center gap-2 py-6">
|
|
{asset.type === 'video'
|
|
? <Film className="w-10 h-10 text-blue-400" />
|
|
: <Image className="w-10 h-10 text-blue-400" />}
|
|
<span className="text-xs" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
|
{asset.metaAssetId ? `Meta ID: ${asset.metaAssetId}` : 'Ready for upload'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info footer */}
|
|
<div className="px-3 py-2.5" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
|
<div className="flex items-start justify-between gap-2 mb-1.5">
|
|
<p className="text-xs font-medium text-white leading-snug line-clamp-2">{asset.name}</p>
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full flex-shrink-0"
|
|
style={{ color: statusColor[asset.status], background: `${statusColor[asset.status]}18` }}>
|
|
{asset.status.toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded"
|
|
style={{ background: 'rgba(255,255,255,0.06)', color: 'rgba(148,163,184,0.6)' }}>
|
|
{asset.type === 'video' ? 'Wan 2.2' : 'Qwen-2512'}
|
|
</span>
|
|
{asset.language && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded uppercase"
|
|
style={{ background: 'rgba(255,255,255,0.06)', color: 'rgba(148,163,184,0.6)' }}>
|
|
{asset.language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ── Reference Slot ───────────────────────────────────────────────────────────
|
|
|
|
interface RefSelection { name: string; preview: string; }
|
|
|
|
function ReferenceSlot({ value, onSelect, onClear, onRemove }: {
|
|
value: RefSelection | null;
|
|
onSelect: (sel: RefSelection) => void;
|
|
onClear: () => void;
|
|
onRemove?: () => void;
|
|
}) {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
onSelect({ name: file.name, preview: URL.createObjectURL(file) });
|
|
e.target.value = '';
|
|
}
|
|
|
|
// Show X when: slot has content (clear it), or slot is removable (remove it)
|
|
const showX = !!value || !!onRemove;
|
|
const handleX = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
if (onRemove) onRemove();
|
|
else onClear();
|
|
};
|
|
|
|
return (
|
|
<div className="relative w-[60px] h-[60px]">
|
|
<motion.button
|
|
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
|
|
style={{
|
|
background: value?.preview ? 'transparent' : 'rgba(255,255,255,0.03)',
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
}}
|
|
onClick={() => inputRef.current?.click()}
|
|
whileHover={{ scale: 1.04 }}
|
|
whileTap={{ scale: 0.96 }}
|
|
title="Add reference image"
|
|
>
|
|
{value?.preview ? (
|
|
<img src={value.preview} alt={value.name} className="w-full h-full object-cover" />
|
|
) : value?.name ? (
|
|
<>
|
|
<Check className="w-4 h-4 text-blue-400" />
|
|
<span className="text-[8px] text-blue-400 font-medium text-center px-1 leading-tight line-clamp-2">{value.name}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>+</span>
|
|
)}
|
|
</motion.button>
|
|
|
|
<AnimatePresence>
|
|
{showX && (
|
|
<motion.button
|
|
className="absolute -top-2 -right-3 w-4 h-4 rounded-full flex items-center justify-center z-10"
|
|
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
|
|
onClick={handleX}
|
|
initial={{ opacity: 0, scale: 0.6 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.6 }}
|
|
transition={{ duration: 0.12 }}
|
|
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
|
|
title={onRemove ? 'Remove slot' : 'Clear selection'}
|
|
>
|
|
<X className="w-2 h-2 text-white" />
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<input ref={inputRef} type="file" accept="image/*,video/*" className="hidden" onChange={handleFile} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkflowInput() {
|
|
const [mode, setMode] = useState<'image' | 'video'>('image');
|
|
const [prompt, setPrompt] = useState('');
|
|
const [keywords, setKeywords] = useState('');
|
|
const [textCopy, setTextCopy] = useState('');
|
|
const [groundTruth, setGroundTruth] = useState<GroundTruthSelection | null>(null);
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
const [refs, setRefs] = useState<(RefSelection | null)[]>([null, null]);
|
|
const anchorRef = useRef<HTMLButtonElement>(null);
|
|
|
|
return (
|
|
<Widget delay={0.04} colSpan={1} className="!p-6 !overflow-visible" style={{ background: '#111216', borderRadius: '28px' }}>
|
|
<div className="flex flex-col gap-6">
|
|
{/* Top Section: Ground Truth & References */}
|
|
<div className="flex items-end gap-6">
|
|
{/* Ground Truth slot */}
|
|
<div className="flex flex-col gap-2">
|
|
<div className="relative">
|
|
<motion.button
|
|
ref={anchorRef}
|
|
onClick={() => setPickerOpen(v => !v)}
|
|
className="w-[60px] h-[60px] rounded-2xl flex flex-col items-center justify-center gap-1 overflow-hidden transition-colors"
|
|
style={{
|
|
background: groundTruth?.preview ? 'transparent' : pickerOpen ? 'rgba(59,130,246,0.12)' : 'rgba(255,255,255,0.03)',
|
|
border: pickerOpen ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(255,255,255,0.08)',
|
|
}}
|
|
whileHover={{ scale: 1.04 }}
|
|
whileTap={{ scale: 0.96 }}
|
|
title="Select Ground Truth image"
|
|
>
|
|
{groundTruth?.preview ? (
|
|
<img src={groundTruth.preview} alt="ground truth" className="w-full h-full object-cover" />
|
|
) : groundTruth?.name ? (
|
|
<>
|
|
<Check className="w-4 h-4 text-green-400" />
|
|
<span className="text-[8px] text-green-400 font-medium text-center px-1 leading-tight line-clamp-2">{groundTruth.name}</span>
|
|
</>
|
|
) : (
|
|
<span className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.2)' }}>{pickerOpen ? '✕' : '+'}</span>
|
|
)}
|
|
</motion.button>
|
|
|
|
{/* Clear selection button */}
|
|
<AnimatePresence>
|
|
{groundTruth && (
|
|
<motion.button
|
|
className="absolute -top-2 right-2 w-4 h-4 rounded-full flex items-center justify-center z-10"
|
|
style={{ background: 'rgba(20,20,30,0.95)', border: '1px solid rgba(255,255,255,0.22)' }}
|
|
onClick={(e) => { e.stopPropagation(); setGroundTruth(null); setPickerOpen(false); }}
|
|
initial={{ opacity: 0, scale: 0.6 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.6 }}
|
|
transition={{ duration: 0.12 }}
|
|
whileHover={{ scale: 1.2, backgroundColor: 'rgba(239,68,68,0.8)' }}
|
|
title="Remove selection"
|
|
>
|
|
<X className="w-2 h-2 text-white" />
|
|
</motion.button>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{pickerOpen && (
|
|
<GroundTruthPicker
|
|
anchorRef={anchorRef}
|
|
onSelect={sel => { setGroundTruth(sel); setPickerOpen(false); }}
|
|
onClose={() => setPickerOpen(false)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
<span className="text-xs font-semibold text-white tracking-wide">Ground Truth</span>
|
|
</div>
|
|
|
|
<div className="flex items-center h-[60px] mb-[26px]">
|
|
<Plus className="w-5 h-5 text-white" />
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-3">
|
|
{refs.map((ref, i) => (
|
|
<ReferenceSlot
|
|
key={i}
|
|
value={ref}
|
|
onSelect={(sel) => setRefs(prev => prev.map((r, idx) => idx === i ? sel : r))}
|
|
onClear={() => setRefs(prev => prev.map((r, idx) => idx === i ? null : r))}
|
|
onRemove={i >= 2 ? () => setRefs(prev => prev.filter((_, idx) => idx !== i)) : undefined}
|
|
/>
|
|
))}
|
|
<motion.button
|
|
title="Add more references"
|
|
className="w-[60px] h-[60px] rounded-2xl flex items-center justify-center transition-colors"
|
|
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}
|
|
onClick={() => setRefs(prev => prev.length < 6 ? [...prev, null] : prev)}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<Plus className="w-5 h-5 text-white/40" />
|
|
</motion.button>
|
|
</div>
|
|
<span className="text-xs font-semibold text-white tracking-wide">References</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Section: Input & Controls */}
|
|
<div className="flex items-end justify-between">
|
|
<div className="flex-1 flex flex-col gap-3">
|
|
<textarea
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
placeholder="What do you want to create?"
|
|
className="w-full bg-transparent text-xl font-medium text-white placeholder-white/20 resize-none outline-none leading-relaxed"
|
|
rows={1}
|
|
/>
|
|
{/* Extra inputs for keywords and text copy as requested */}
|
|
<div className="flex items-center gap-3 pt-2" style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
|
<input
|
|
type="text"
|
|
value={keywords}
|
|
onChange={(e) => setKeywords(e.target.value)}
|
|
placeholder="Keywords (comma separated)"
|
|
className="bg-transparent text-sm text-white/80 placeholder-white/20 outline-none flex-1 min-w-0"
|
|
/>
|
|
<span className="text-white/10">|</span>
|
|
<input
|
|
type="text"
|
|
value={textCopy}
|
|
onChange={(e) => setTextCopy(e.target.value)}
|
|
placeholder='Text copy inside ""'
|
|
className="bg-transparent text-sm text-white/80 placeholder-white/20 outline-none flex-1 min-w-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4 ml-6 pl-4">
|
|
{/* Toggle */}
|
|
<div className="flex items-center rounded-full p-1" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
|
<button
|
|
onClick={() => setMode('image')}
|
|
className={`px-5 py-2.5 rounded-full text-sm font-semibold transition-all ${mode === 'image' ? 'text-black shadow-lg' : 'text-white/60'}`}
|
|
style={{ background: mode === 'image' ? '#22c55e' : 'transparent' }}
|
|
>
|
|
Image
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('video')}
|
|
className={`px-5 py-2.5 rounded-full text-sm font-semibold transition-all ${mode === 'video' ? 'text-black shadow-lg' : 'text-white/60'}`}
|
|
style={{ background: mode === 'video' ? '#22c55e' : 'transparent' }}
|
|
>
|
|
Video
|
|
</button>
|
|
</div>
|
|
|
|
{/* Send Button */}
|
|
<button className="w-[44px] h-[44px] rounded-full bg-zinc-200 flex items-center justify-center hover:bg-white transition-colors flex-shrink-0">
|
|
<ArrowRight className="w-5 h-5 text-black" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Widget>
|
|
);
|
|
}
|
|
|
|
function TheStudio() {
|
|
const { activeAssets } = useMarketingStore();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header row */}
|
|
<Widget delay={0} colSpan={1}>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-white mb-0.5 flex items-center gap-2">
|
|
<Clapperboard className="w-4 h-4 text-blue-400" /> Visual AI Engine
|
|
</h3>
|
|
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
ComfyUI workflows (Wan 2.2 & Qwen-Image 2512) feed directly into the Meta Asset Library
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<GhostButton>
|
|
<span className="flex items-center gap-1.5"><Upload className="w-3.5 h-3.5" /> Sync to Meta</span>
|
|
</GhostButton>
|
|
<GhostButton>
|
|
<span className="flex items-center gap-1.5"><Play className="w-3.5 h-3.5 text-blue-400" /> Trigger Render</span>
|
|
</GhostButton>
|
|
</div>
|
|
</div>
|
|
</Widget>
|
|
|
|
{/* ComfyUI Workflow Input Panel */}
|
|
<WorkflowInput />
|
|
|
|
{/* Asset grid */}
|
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
|
{activeAssets.map((asset, i) => (
|
|
<motion.div key={asset.id}
|
|
initial={{ opacity: 0, y: 14 }} animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: i * 0.07 }}>
|
|
<AssetCard asset={asset} />
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// TAB 2 — Campaign Command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function CampaignCommand() {
|
|
const { campaigns, adInsights } = useMarketingStore();
|
|
const { formatAmount, rate } = useCurrency();
|
|
|
|
const totalSpend = campaigns.reduce((s, c) => s + c.lifetimeSpend / 100, 0);
|
|
const totalImpr = campaigns.reduce((s, c) => s + c.impressions, 0);
|
|
const avgCtr = campaigns.reduce((s, c) => s + c.ctr, 0) / campaigns.length;
|
|
const avgCpa = campaigns.reduce((s, c) => s + c.cpa, 0) / campaigns.length;
|
|
|
|
// Build spend-over-time from insights (last 14 days)
|
|
const spendData = adInsights.slice(0, 14).map((d) => ({
|
|
date: d.date.slice(5), // MM-DD
|
|
spend: Math.round(d.spend * rate),
|
|
impressions: Math.round(d.impressions / 1000),
|
|
})).reverse();
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* KPI row */}
|
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
|
<StatCard icon={Megaphone} label="Active Campaigns" value={String(campaigns.filter(c => c.status === 'ACTIVE').length)} sub={`${campaigns.length} total campaigns`} glowColor="rgba(59,130,246,0.2)" delay={0} />
|
|
<StatCard icon={DollarSign} label="Total Spend" value={formatAmount(totalSpend, { compact: true })} sub="Lifetime across all campaigns" glowColor="rgba(34,211,238,0.2)" delay={0.06} />
|
|
<StatCard icon={Eye} label="Impressions" value={`${(totalImpr / 1000).toFixed(0)}K`} sub="Total ad impressions" glowColor="rgba(251,191,36,0.2)" delay={0.12} />
|
|
<StatCard icon={MousePointerClick} label="Avg CTR" value={`${avgCtr.toFixed(2)}%`} sub={`Avg CPA: ${formatAmount(avgCpa)}`} glowColor="rgba(167,139,250,0.2)" delay={0.18} />
|
|
</div>
|
|
|
|
{/* Spend chart + campaign list */}
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
{/* Spend over time chart */}
|
|
<Widget delay={0.24} colSpan={1}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<TrendingUp className="w-4 h-4 text-blue-400" /> Spend vs Impressions
|
|
</h3>
|
|
<LiveBadge />
|
|
</div>
|
|
<div className="h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={spendData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="gSpend" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
|
|
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="gImpr" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.28} />
|
|
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
|
<XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} />
|
|
<Area type="monotone" dataKey="spend" stroke="#3b82f6" strokeWidth={2} fillOpacity={1} fill="url(#gSpend)" />
|
|
<Area type="monotone" dataKey="impressions" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gImpr)" />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</Widget>
|
|
|
|
{/* Campaign list */}
|
|
<Widget delay={0.28} colSpan={1}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<SlidersHorizontal className="w-4 h-4 text-blue-400" /> Campaigns
|
|
</h3>
|
|
<GhostButton>
|
|
<span className="flex items-center gap-1.5 text-xs"><PlusCircle className="w-3.5 h-3.5" /> New</span>
|
|
</GhostButton>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{campaigns.map((c) => (
|
|
<motion.div key={c.id} className="flex items-center justify-between p-3 rounded-xl" style={INNER}
|
|
whileHover={{ scale: 1.01 }} transition={{ duration: 0.15 }}>
|
|
<div className="flex-1 min-w-0 mr-3">
|
|
<p className="text-sm font-medium text-white truncate">{c.name}</p>
|
|
<p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
|
{formatAmount(c.lifetimeSpend / 100)} · {c.impressions.toLocaleString()} impr.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<span className="text-xs font-medium" style={{ color: c.roi > 20 ? '#4ade80' : '#fbbf24' }}>
|
|
{c.roi.toFixed(1)}% ROI
|
|
</span>
|
|
<CampaignStatusBadge status={c.status} />
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</Widget>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// TAB 3 — Intelligence & ROI
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function IntelligenceROI() {
|
|
const { campaigns, adInsights } = useMarketingStore();
|
|
const { formatAmount, symbol, rate } = useCurrency();
|
|
|
|
const cpaData = adInsights.slice(0, 7).map((d) => ({
|
|
date: d.date.slice(5),
|
|
cpa: Math.round(d.cpa * rate),
|
|
roi: d.roi,
|
|
})).reverse();
|
|
|
|
const adSetPerf = [
|
|
{ name: '3BHK Dubai', ctr: 2.1, cpa: Math.round(210 * rate), spend: Math.round(3400 * rate) },
|
|
{ name: 'PH Retarget', ctr: 3.2, cpa: Math.round(2100 * rate), spend: Math.round(5800 * rate) },
|
|
{ name: '1BHK Lookalike', ctr: 1.8, cpa: Math.round(270 * rate), spend: Math.round(980 * rate) },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* CPA / ROI KPIs */}
|
|
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
|
|
<StatCard icon={DollarSign} label="Avg CPA" value={formatAmount(campaigns.reduce((s,c)=>s+c.cpa,0)/campaigns.length)} sub="Cost per acquisition" glowColor="rgba(34,211,238,0.2)" delay={0} />
|
|
<StatCard icon={TrendingUp} label="Portfolio ROI" value={`${(campaigns.reduce((s,c)=>s+c.roi,0)/campaigns.length).toFixed(1)}%`} sub="Blended across all ad sets" glowColor="rgba(74,222,128,0.2)" delay={0.06} />
|
|
<StatCard icon={Activity} label="Avg CTR" value={`${(campaigns.reduce((s,c)=>s+c.ctr,0)/campaigns.length).toFixed(2)}%`} sub="Click-through rate" glowColor="rgba(167,139,250,0.2)" delay={0.12} />
|
|
<StatCard icon={Zap} label="Optimization Runs" value="47" sub="Agent actions today" glowColor="rgba(251,191,36,0.2)" delay={0.18} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
{/* CPA trend */}
|
|
<Widget delay={0.24}>
|
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-cyan-400" /> CPA Trend (7 Days)
|
|
</h3>
|
|
<div className="h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={cpaData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="gCpa" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.35} />
|
|
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
|
<XAxis dataKey="date" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(34,211,238,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#67e8f9', fontSize: 12 }} />
|
|
<Area type="monotone" dataKey="cpa" stroke="#22d3ee" strokeWidth={2} fillOpacity={1} fill="url(#gCpa)" name={`CPA (${symbol})`} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</Widget>
|
|
|
|
{/* Ad-set CPA bar chart */}
|
|
<Widget delay={0.28}>
|
|
<h3 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-blue-400" /> Ad Set Performance
|
|
</h3>
|
|
<div className="h-52">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={adSetPerf} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
|
|
<XAxis dataKey="name" stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<YAxis stroke="rgba(148,163,184,0.3)" fontSize={10} tickLine={false} axisLine={false} />
|
|
<Tooltip contentStyle={{ backgroundColor: 'rgba(8,10,18,0.92)', border: '1px solid rgba(59,130,246,0.2)', borderRadius: 10 }} labelStyle={{ color: 'rgba(148,163,184,0.8)', fontSize: 11 }} itemStyle={{ color: '#93c5fd', fontSize: 12 }} />
|
|
<Bar dataKey="spend" fill="#3b82f6" radius={[4, 4, 0, 0]} name={`Spend (${symbol})`} opacity={0.8} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</Widget>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Live Optimization Feed (shared across tabs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const EVENT_ICON: Record<LiveEventType, { icon: LucideIcon; color: string; bg: string; label: string }> = {
|
|
pause: { icon: AlertTriangle, color: '#fbbf24', bg: 'rgba(251,191,36,0.12)', label: 'Paused' },
|
|
shift: { icon: ArrowRightLeft, color: '#60a5fa', bg: 'rgba(96,165,250,0.12)', label: 'Budget Shift' },
|
|
create: { icon: PlusCircle, color: '#4ade80', bg: 'rgba(74,222,128,0.12)', label: 'Created' },
|
|
optimize: { icon: Zap, color: '#a78bfa', bg: 'rgba(167,139,250,0.12)', label: 'Optimized' },
|
|
alert: { icon: AlertTriangle, color: '#f87171', bg: 'rgba(248,113,113,0.12)', label: 'Alert' },
|
|
rotate: { icon: RefreshCw, color: '#22d3ee', bg: 'rgba(34,211,238,0.12)', label: 'Rotated' },
|
|
};
|
|
|
|
function LiveEventItem({ event }: { event: LiveOptimizationEvent }) {
|
|
const cfg = EVENT_ICON[event.type];
|
|
const { formatText } = useCurrency();
|
|
const Icon = cfg.icon;
|
|
return (
|
|
<motion.div
|
|
className="flex items-start gap-3 p-3 rounded-xl"
|
|
style={INNER}
|
|
initial={{ opacity: 0, x: -12, scale: 0.97 }}
|
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
exit={{ opacity: 0, x: 12, scale: 0.95 }}
|
|
transition={{ duration: 0.3, ease: [0.4, 0, 0.2, 1] }}
|
|
layout
|
|
>
|
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{ background: cfg.bg }}>
|
|
<Icon className="w-3.5 h-3.5" style={{ color: cfg.color }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider" style={{ color: cfg.color }}>{cfg.label}</span>
|
|
{event.campaignName && (
|
|
<span className="text-[10px] px-1.5 py-px rounded" style={{ background: 'rgba(255,255,255,0.05)', color: 'rgba(148,163,184,0.6)' }}>
|
|
{event.campaignName}
|
|
</span>
|
|
)}
|
|
{event.value && (
|
|
<span className="text-[10px] font-medium ml-auto" style={{ color: cfg.color }}>{formatText(event.value)}</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs leading-relaxed" style={{ color: 'rgba(148,163,184,0.75)' }}>{formatText(event.message)}</p>
|
|
<p className="text-[10px] mt-1" style={{ color: 'rgba(148,163,184,0.35)' }}>
|
|
{event.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function LiveOptimizationFeed() {
|
|
const { liveEvents } = useMarketingStore();
|
|
|
|
return (
|
|
<Widget delay={0.4} colSpan={1}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
|
<Activity className="w-4 h-4 text-blue-400" /> Live Optimization Feed
|
|
</h3>
|
|
<LiveBadge />
|
|
</div>
|
|
<div className="space-y-2 max-h-72 overflow-y-auto custom-scrollbar pr-1">
|
|
{liveEvents.length === 0 ? (
|
|
<div className="rounded-xl border border-white/8 bg-white/[0.02] p-4 text-sm text-zinc-400">
|
|
No live optimization events are available. Connect the production ad-platform integrations to populate this stream.
|
|
</div>
|
|
) : (
|
|
<AnimatePresence mode="popLayout" initial={false}>
|
|
{liveEvents.slice(0, 8).map((ev) => (
|
|
<LiveEventItem key={ev.id} event={ev} />
|
|
))}
|
|
</AnimatePresence>
|
|
)}
|
|
</div>
|
|
</Widget>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// TAB 4 — War Room (Meta Graph Settings)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function WarRoom() {
|
|
const { settings, setMetaSettings } = useMarketingStore();
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const handleSave = () => {
|
|
setMetaSettings({ isConnected: true });
|
|
setSaved(true);
|
|
setTimeout(() => setSaved(false), 2500);
|
|
};
|
|
|
|
const fields: Array<{ key: keyof typeof settings; label: string; desc: string; placeholder: string; type?: string }> = [
|
|
{ key: 'metaAccessToken', label: 'Meta System User Access Token', desc: 'Long-lived system user token from Meta Business Manager', placeholder: 'EAAxxxxxxxx...' , type: 'password' },
|
|
{ key: 'metaAdAccountId', label: 'Ad Account ID', desc: 'Format: act_XXXXXXXXXX', placeholder: 'act_123456789' },
|
|
{ key: 'metaBusinessId', label: 'Business Portfolio ID', desc: 'Found in Meta Business Settings → Business Info', placeholder: '1234567890' },
|
|
{ key: 'metaAppId', label: 'App ID', desc: 'From Meta Developers → Your App', placeholder: '9876543210' },
|
|
{ key: 'whatsappPhoneNumberId',label: 'WhatsApp Phone Number ID', desc: 'Required for WhatsApp Business API integration', placeholder: '1098765432' },
|
|
];
|
|
|
|
const assetLinks = [
|
|
{ label: 'Instagram Business Page', href: '#', icon: Link2 },
|
|
{ label: 'Facebook Business Page', href: '#', icon: Link2 },
|
|
{ label: 'WhatsApp Business Account', href: '#', icon: Link2 },
|
|
{ label: 'Ad Library (Creative Hub)', href: '#', icon: Link2 },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Connection status banner */}
|
|
<Widget delay={0} colSpan={1}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="relative">
|
|
<div className={`w-3 h-3 rounded-full ${settings.isConnected ? 'bg-green-500' : 'bg-zinc-600'}`} />
|
|
{settings.isConnected && <div className="absolute inset-0 rounded-full bg-green-500 status-pulse" />}
|
|
</div>
|
|
<div>
|
|
<p className="text-sm font-medium text-white">Meta Graph API</p>
|
|
<p className="text-xs" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
|
{settings.isConnected ? 'Connected — System User Token Active' : 'Not connected — enter credentials below'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="w-4 h-4" style={{ color: 'rgba(148,163,184,0.4)' }} />
|
|
<span className="text-xs font-medium" style={{ color: settings.isConnected ? '#4ade80' : 'rgba(148,163,184,0.4)' }}>
|
|
{settings.isConnected ? 'ONLINE' : 'OFFLINE'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Widget>
|
|
|
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
|
{/* API Credentials */}
|
|
<Widget delay={0.08}>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Settings2 className="w-4 h-4 text-blue-400" />
|
|
<h3 className="text-sm font-semibold text-white">API Credentials</h3>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{fields.map(({ key, label, desc, placeholder, type }) => (
|
|
<div key={key}>
|
|
<p className="text-xs font-medium text-white mb-0.5">{label}</p>
|
|
<p className="text-[10px] mb-1.5" style={{ color: 'rgba(148,163,184,0.5)' }}>{desc}</p>
|
|
<DarkInput
|
|
type={type ?? 'text'}
|
|
value={String(settings[key])}
|
|
onChange={(v) => setMetaSettings({ [key]: v } as Partial<typeof settings>)}
|
|
placeholder={placeholder}
|
|
/>
|
|
</div>
|
|
))}
|
|
<motion.button
|
|
type="button"
|
|
className="w-full py-2.5 mt-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 transition-all"
|
|
style={{ background: saved ? 'rgba(74,222,128,0.15)' : 'hsl(var(--accent))', color: saved ? '#4ade80' : 'hsl(var(--accent-fg))' }}
|
|
onClick={handleSave}
|
|
whileHover={{ scale: 1.02 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
{saved ? <><Check className="w-4 h-4" /> Saved & Connected</> : 'Save & Connect to Meta'}
|
|
</motion.button>
|
|
</div>
|
|
</Widget>
|
|
|
|
{/* Business Asset Links */}
|
|
<Widget delay={0.12}>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<Link2 className="w-4 h-4 text-blue-400" />
|
|
<h3 className="text-sm font-semibold text-white">Business Asset Management</h3>
|
|
</div>
|
|
<p className="text-xs mb-4" style={{ color: 'rgba(148,163,184,0.55)' }}>
|
|
Link WhatsApp, Instagram, and Facebook Pages to your Meta Business Portfolio for unified campaign management.
|
|
</p>
|
|
<div className="space-y-2">
|
|
{assetLinks.map(({ label, icon: Icon }) => (
|
|
<div key={label} className="flex items-center justify-between p-3 rounded-xl" style={INNER}>
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="w-4 h-4 text-blue-400" />
|
|
<span className="text-sm text-white">{label}</span>
|
|
</div>
|
|
<GhostButton>
|
|
<span className="text-xs">Configure</span>
|
|
</GhostButton>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Permissions */}
|
|
<div className="mt-4 p-3 rounded-xl" style={{ background: 'rgba(59,130,246,0.06)', border: '1px solid rgba(59,130,246,0.15)' }}>
|
|
<p className="text-xs font-medium text-blue-300 mb-1">Required Scopes</p>
|
|
{['ads_management', 'ads_read', 'business_management', 'pages_manage_ads', 'whatsapp_business_management'].map((scope) => (
|
|
<div key={scope} className="flex items-center gap-2 py-0.5">
|
|
<Check className="w-3 h-3 text-blue-400" />
|
|
<span className="text-[11px] font-mono" style={{ color: 'rgba(148,163,184,0.7)' }}>{scope}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Widget>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tab Bar
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
type TabId = 'studio' | 'command' | 'intelligence' | 'war-room' | 'marketing' | 'dream-weaver';
|
|
|
|
const TABS: Array<{ id: TabId; label: string; icon: LucideIcon }> = [
|
|
{ id: 'studio', label: 'The Studio', icon: Clapperboard },
|
|
{ id: 'command', label: 'Campaign Command', icon: Megaphone },
|
|
{ id: 'intelligence', label: 'Intelligence & ROI', icon: BarChart3 },
|
|
{ id: 'war-room', label: 'War Room', icon: Globe },
|
|
{ id: 'marketing', label: 'Marketing', icon: TrendingUp },
|
|
{ id: 'dream-weaver', label: 'Dream Weaver', icon: WandSparkles },
|
|
];
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Root export
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function Catalyst() {
|
|
const { activeTab, setActiveTab } = useMarketingStore();
|
|
|
|
const panels: Record<TabId, React.ReactNode> = {
|
|
'studio': <TheStudio />,
|
|
'command': <CampaignCommand />,
|
|
'intelligence': <IntelligenceROI />,
|
|
'war-room': <WarRoom />,
|
|
'marketing': <CatalystMarketingTab />,
|
|
'dream-weaver': <CatalystDreamWeaverTab />,
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-5 pb-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-white tracking-tight flex items-center gap-2">
|
|
<Megaphone className="w-5 h-5 text-blue-400" /> The Catalyst
|
|
</h2>
|
|
<p className="text-xs mt-0.5" style={{ color: 'rgba(148,163,184,0.5)' }}>
|
|
Autonomous Deployment Engine · Meta Marketing API
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<motion.div animate={{ opacity: [1, 0.4, 1] }} transition={{ repeat: Infinity, duration: 2 }}
|
|
className="w-1.5 h-1.5 rounded-full bg-blue-400" />
|
|
<span className="text-xs font-medium" style={{ color: '#60a5fa' }}>Claw Agent Active</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab bar */}
|
|
<div className="flex items-center gap-1 p-1 rounded-2xl w-fit" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)' }}>
|
|
{TABS.map((tab) => {
|
|
const Icon = tab.icon;
|
|
const isActive = activeTab === tab.id;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className="relative flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-colors outline-none"
|
|
style={{ color: isActive ? 'white' : 'rgba(148,163,184,0.6)' }}
|
|
>
|
|
{isActive && (
|
|
<motion.div layoutId="catalyst-tab-bg" className="absolute inset-0 rounded-xl"
|
|
style={{ background: 'rgba(59,130,246,0.15)', border: '1px solid rgba(59,130,246,0.25)' }}
|
|
transition={{ type: 'spring', stiffness: 380, damping: 30 }} />
|
|
)}
|
|
<Icon className="w-4 h-4 relative z-10" style={{ color: isActive ? '#60a5fa' : 'rgba(148,163,184,0.5)' }} />
|
|
<span className="relative z-10">{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Tab panel */}
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={activeTab}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -8 }}
|
|
transition={{ duration: 0.25, ease: [0.4, 0, 0.2, 1] }}
|
|
>
|
|
{panels[activeTab]}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
{/* Live Optimization Feed — hidden on Dream Weaver because generation has its own status surface. */}
|
|
{activeTab !== 'dream-weaver' && <LiveOptimizationFeed />}
|
|
</section>
|
|
);
|
|
}
|