import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePrivy } from '@privy-io/react-auth';
import { useCopySubscriptions } from '../hooks/useCopySubscriptions';
import type { CopyConfig, CopySub } from '../hooks/useCopySubscriptions';
import CopyConfigModal from '../components/copy/CopyConfigModal';
import { useT } from '../i18n/LanguageContext';
import { addrHue, whaleStyle, categoryGlyph, whaleCodename, whaleCodenamesUnique, whaleHorizon } from '../utils/whales';
type WhaleMeta = { avgBuy: number | null; sellRate: number | null; category: string | null; totalTrades: number; firstSeen: string | null; lastSeen: string | null };
type SortKey = 'active' | 'pnl' | 'open' | 'copies';
type LeaderPnl = { leader: string; pnl: number; realized: number; unrealized: number; value: number; openCount: number; closedCount: number };
function allocSummary(c: CopyConfig, t: (k: string, p?: Record<string, string | number>) => string): string {
const head = c.allocMode === 'percent'
? t('copy.allocPercent', { v: c.allocValue })
: c.allocMode === 'fixed'
? t('copy.allocFixed', { v: c.allocValue })
: t('copy.allocProp');
return `${head} · ${t('copy.allocTail', { max: c.maxPerTrade, exp: c.maxExposure })}`;
}
/**
* 📋 Мои копии — управление копи-подписками. Demo-режим (localStorage, без денег).
* Реальный движок копирования подключим после wallet-auth + vault (Шаг 4).
*/
export default function CopyTradingPage() {
const navigate = useNavigate();
const { authenticated, user } = usePrivy();
const { subs, upsert, setStatus } = useCopySubscriptions();
const [editing, setEditing] = useState<CopySub | null>(null);
const [sort, setSort] = useState<SortKey>('active');
const { t, lang } = useT();
// Live per-leader PnL of our copies (pushed by the copy-trader daemon).
const [pnl, setPnl] = useState<Record<string, LeaderPnl>>({});
// Whale trading-style meta (avgBuy/sellRate) for the style chip, from top-traders.
const [meta, setMeta] = useState<Record<string, WhaleMeta>>({});
useEffect(() => {
let alive = true;
fetch('/api/signals/top-traders?days=0&limit=300').then(r => r.json()).then(d => {
if (!alive || !d?.data) return;
const m: Record<string, WhaleMeta> = {};
for (const w of d.data) m[String(w.address).toLowerCase()] = { avgBuy: w.avgBuy ?? null, sellRate: w.sellRate ?? null, category: w.topCategory ?? null, totalTrades: w.totalTrades ?? 0, firstSeen: w.firstSeen ?? null, lastSeen: w.lastSeen ?? null };
setMeta(m);
}).catch(() => {});
return () => { alive = false; };
}, []);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const uid = (user?.id || '').toLowerCase();
const r = await fetch(`/api/copy/pnl${uid ? `?user=${encodeURIComponent(uid)}` : ''}`);
const d = await r.json();
if (!alive || !d?.data?.leaders) return;
const map: Record<string, LeaderPnl> = {};
for (const l of d.data.leaders as LeaderPnl[]) map[l.leader.toLowerCase()] = l;
setPnl(map);
} catch { /* ignore */ }
};
load();
const id = setInterval(load, 30000);
return () => { alive = false; clearInterval(id); };
}, [user?.id]);
const totalCopies = (p?: LeaderPnl) => (p?.openCount ?? 0) + (p?.closedCount ?? 0);
const sortedSubs = subs.filter(s => s.status !== 'archived').sort((a, b) => {
const pa = pnl[a.address.toLowerCase()];
const pb = pnl[b.address.toLowerCase()];
if (sort === 'active') {
const aa = a.status === 'active', ba = b.status === 'active';
if (aa !== ba) return aa ? -1 : 1; // active first
return (pb?.pnl ?? 0) - (pa?.pnl ?? 0); // then by P&L
}
if (sort === 'pnl') return (pb?.pnl ?? 0) - (pa?.pnl ?? 0);
if (sort === 'open') return (pb?.openCount ?? 0) - (pa?.openCount ?? 0);
if (sort === 'copies') return totalCopies(pb) - totalCopies(pa);
return 0;
});
const codenames = whaleCodenamesUnique(sortedSubs.map(s => s.address), lang);
const SORTS: { key: SortKey; label: string }[] = [
{ key: 'active', label: t('copy.sortActive') },
{ key: 'pnl', label: t('copy.sortPnl') },
{ key: 'open', label: t('copy.sortOpen') },
{ key: 'copies', label: t('copy.sortCopies') },
];
return (
<div className="mc-page">
<header className="mc-header">
<h1 className="mc-title">{t('copy.title')}</h1>
<p className="mc-sub">{t('copy.sub')}</p>
</header>
{authenticated ? (
<div className="mc-demo" style={{ borderColor: '#16a34a', color: '#22c55e', background: 'rgba(22,163,74,.08)' }}>
{t('copy.bannerActive')}
</div>
) : (
<div className="mc-demo">
{t('copy.bannerDemo')}
</div>
)}
{subs.length === 0 ? (
<div className="wf-empty">
<div className="wf-empty-icon">📋</div>
<div className="wf-empty-title">{t('copy.emptyTitle')}</div>
<div className="wf-empty-sub">
{t('copy.emptySub')}
</div>
<button className="mc-add" onClick={() => navigate('/')}>{t('copy.addLeader')}</button>
</div>
) : (
<div className="mc-list">
<div className="lead-sortbar">
{SORTS.map(s => (
<button key={s.key} className={`lead-sort ${sort === s.key ? 'active' : ''}`} onClick={() => setSort(s.key)}>
{s.label}
</button>
))}
</div>
{sortedSubs.map(s => (
<div key={s.address} className={`mc-card ${s.status === 'paused' ? 'mc-paused' : ''}`}>
<div className="mc-card-head" onClick={() => navigate('/whale', { state: { address: s.address } })}>
{(() => { const m = meta[s.address.toLowerCase()]; const style = m ? whaleStyle(m.avgBuy, m.sellRate) : null; return (
<div className="mc-avatar" style={{ background: style ? style.color : `hsl(${addrHue(s.address)} 45% 34%)`, fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{categoryGlyph(m?.category)}
</div> ); })()}
<div className="mc-id">
<span className="mc-name">
{codenames.get(s.address.toLowerCase()) ?? whaleCodename(s.address, lang)}
{(() => {
const m = meta[s.address.toLowerCase()];
const style = m ? whaleStyle(m.avgBuy, m.sellRate) : null;
const horizon = m ? whaleHorizon(m.totalTrades, m.firstSeen, m.lastSeen) : null;
return (
<>
{style && <span title={t(style.key)} style={{ marginLeft: 5, fontSize: 12 }}>{style.icon}</span>}
{horizon && <span title={t(horizon.key)} style={{ marginLeft: 4, fontSize: 12 }}>{horizon.icon}</span>}
</>
);
})()}
</span>
<span className={`mc-status ${s.status}`}>
{s.status === 'active' ? t('copy.statusActive') : t('copy.statusPaused')}
</span>
</div>
</div>
<div className="mc-alloc">{allocSummary(s.config, t)}</div>
{(() => {
const p = pnl[s.address.toLowerCase()];
if (!p || (p.openCount === 0 && p.closedCount === 0)) {
return <div className="mc-pnl" style={{ color: '#64748b', fontSize: 13, margin: '2px 0 4px' }}>{t('copy.pnlNone')}</div>;
}
const up = p.pnl >= 0;
const sign = (n: number) => `${n >= 0 ? '+' : '−'}$${Math.abs(n).toFixed(2)}`;
return (
<div className="mc-pnl" style={{ fontSize: 13, margin: '2px 0 4px' }}>
<span style={{ color: up ? '#22c55e' : '#ef4444', fontWeight: 600 }}>PnL: {sign(p.pnl)}</span>
<span style={{ color: '#94a3b8', fontWeight: 400 }}> · {t('copy.pnlRealized')} {sign(p.realized)} · {p.openCount} {t('copy.pnlOpen')} · {p.closedCount} {t('copy.pnlClosed')}</span>
</div>
);
})()}
<div className="mc-flags">
{s.config.categories.length > 0 && (
<span className="mc-flag">{s.config.categories.join(', ')}</span>
)}
<span className="mc-flag">{t('copy.flagEntry', { min: s.config.priceMin, max: s.config.priceMax })}</span>
{s.config.mirrorExits && <span className="mc-flag">{t('copy.flagMirror')}</span>}
{s.config.drawdownStop > 0 && <span className="mc-flag">{t('copy.flagStop', { x: s.config.drawdownStop })}</span>}
</div>
<div className="mc-actions">
<button className="mc-btn" onClick={() => setEditing(s)}>{t('copy.btnSettings')}</button>
<button className="mc-btn" onClick={() =>
setStatus(s.address, s.status === 'active' ? 'paused' : 'active')}>
{s.status === 'active' ? t('copy.btnPause') : t('copy.btnResume')}
</button>
<button className="mc-btn mc-btn-stop" onClick={() => setStatus(s.address, 'archived')}>{t('copy.btnStop')}</button>
</div>
</div>
))}
<button className="mc-add mc-add-inline" onClick={() => navigate('/')}>{t('copy.addLeader')}</button>
</div>
)}
{editing && (
<CopyConfigModal
leaderLabel={whaleCodename(editing.address, lang)}
initial={editing.config}
editing
onSave={(config) => { upsert(editing.address, editing.label, config); setEditing(null); }}
onClose={() => setEditing(null)}
/>
)}
</div>
);
}
📜 Git History
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...