← Back
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';
import '../redesign/editorial.css';

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 pk-copy">
      <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 => {
            const isFade = s.config.direction === 'fade';
            return (
            <div key={s.address} className={`mc-card ${s.status === 'paused' ? 'mc-paused' : ''} ${isFade ? 'mc-fade' : ''}`}>
              <div className="mc-card-head" onClick={() => navigate('/whale', { state: { address: s.address, mode: isFade ? 'fade' : 'copy' } })}>
                {(() => { const m = meta[s.address.toLowerCase()]; const style = m ? whaleStyle(m.avgBuy, m.sellRate) : null; return (
                <div className="mc-avatar" style={{ background: isFade ? '#5b1a24' : (style ? style.color : `hsl(${addrHue(s.address)} 45% 34%)`), fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
                  {isFade ? '💀' : 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}`}>
                    {isFade && <span className="mc-fade-tag">💀 {lang === 'ru' ? 'Фейд' : 'Fade'}</span>}
                    {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

895104ffeat(poli): skull avatar+tag for fade copies, fix profile bottom padding under nav10 days ago
9dfe057feat(poli): editorial Wallet/MarketDetail/Copy-subs + serif-var fix (chunk 8)10 days ago
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...