← Back
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useWhaleProfile } from '../../hooks/useWhaleProfile';
import { useWhaleAlerts } from '../../hooks/useWhaleAlerts';
import { formatVolume, timeAgo } from '../../utils/format';
import { whaleCodename, categoryGlyph, whaleStyle, whaleHorizon } from '../../utils/whales';
import { useT } from '../../i18n/LanguageContext';
const PAGE_SIZE = 50;

interface Props {
  address: string;
}

export default function WhaleProfile({ address }: Props) {
  const { t: tr, lang } = useT();
  const [page, setPage] = useState(0);
  // Reset to the first page when switching whales (adjust state during render —
  // React's recommended pattern over an effect, avoids the extra render pass).
  const [prevAddress, setPrevAddress] = useState(address);
  if (address !== prevAddress) {
    setPrevAddress(address);
    setPage(0);
  }

  const { profile, trades, topMarkets, equity, loading, backfilling, error } =
    useWhaleProfile(address, PAGE_SIZE, page * PAGE_SIZE);
  const { alerts, subscribe, unsubscribe } = useWhaleAlerts();
  const navigate = useNavigate();

  const subscribed = alerts.some(a => a.whaleAddress === address.toLowerCase());
  const totalTrades = profile?.totalTrades ?? 0;
  const totalPages = Math.max(1, Math.ceil(totalTrades / PAGE_SIZE));

  if (loading) {
    return (
      <div className="wp-loading">
        <div className="wp-skel-header" />
        <div className="wp-skel-stats" />
        <div className="wp-skel-list" />
      </div>
    );
  }

  if (error || !profile) {
    return (
      <div className="wp-error">
        <div className="wp-error-icon">&#x1F40B;</div>
        <div className="wp-error-msg">{error || 'Whale not found'}</div>
      </div>
    );
  }

  // Max drawdown ($) derived from the resolved-PnL equity curve (real data, not invented).
  let maxDD = 0;
  if (equity && equity.length > 1) {
    let cum = 0, peak = 0;
    for (const p of equity) { cum += p.pnl; if (cum > peak) peak = cum; const drop = peak - cum; if (drop > maxDD) maxDD = drop; }
  }

  return (
    <div className="wp-container">
      {/* Header */}
      <div className="wp-header">
        <div className="wp-avatar">{categoryGlyph(profile.topCategory)}</div>
        <div className="wp-info">
          <div className="wp-name">{whaleCodename(profile.address, lang)}</div>
          <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', marginTop: 2 }}>
            {profile.topCategory && (
              <span className="wp-cat">{profile.topCategory}</span>
            )}
            {(() => {
              const style = whaleStyle(profile.avgBuy, profile.sellRate);
              return style ? <span title={tr(style.key)} style={{ fontSize: 12, fontWeight: 700, color: style.color }}>{style.icon} {tr(style.key)}</span> : null;
            })()}
            {(() => {
              const h = whaleHorizon(profile.totalTrades, profile.firstSeen, profile.lastSeen);
              return h ? <span title={tr(h.key)} style={{ fontSize: 12 }}>{h.icon} {tr(h.key)}</span> : null;
            })()}
          </div>
          {backfilling && (
            <span className="wp-backfill">{tr('wp.backfill')}</span>
          )}
        </div>
        <button
          className={`wp-alert-btn ${subscribed ? 'wp-alert-on' : ''}`}
          onClick={() => (subscribed ? unsubscribe(profile.address) : subscribe(profile.address))}
          title={subscribed ? tr('wp.unsub') : tr('wp.sub')}
        >
          {subscribed ? tr('wp.tracked') : tr('wp.alert')}
        </button>
      </div>

      {/* Stats Grid */}
      <div className="wp-stats">
        <StatBox label="Total Volume" value={formatVolume(profile.totalVolume)} />
        <StatBox label="Trades" value={String(profile.totalTrades)} />
        <StatBox label="Avg Size" value={formatVolume(profile.avgTradeSize)} />
        <StatBox label="Largest" value={formatVolume(profile.largestTrade)} />
        {profile.winRate != null && (
          <StatBox label={`Win Rate (${profile.resolvedTrades} res.)`} value={`${(profile.winRate * 100).toFixed(0)}%`} />
        )}
        {profile.totalPnl != null && (
          <StatBox label="Total P&L" tone={profile.totalPnl >= 0 ? 'profit' : 'loss'}
            value={`${profile.totalPnl >= 0 ? '+' : '−'}${formatVolume(Math.abs(profile.totalPnl))}`} />
        )}
        {maxDD > 0 && (
          <StatBox label="Max Drawdown" tone="loss" value={`−${formatVolume(maxDD)}`} />
        )}
        {profile.avgBuy != null && (
          <StatBox label="Avg Buy" value={`${(profile.avgBuy * 100).toFixed(0)}¢`} />
        )}
        {profile.firstSeen && (
          <StatBox label="Active Since" value={formatDate(profile.firstSeen)} />
        )}
      </div>

      {/* Динамика P&L — полная история (backend-агрегат /equity по ВСЕМ резолвнутым
          сделкам, не постранично), с осями времени и денег. */}
      <EquityChart equity={equity} />

      {/* Copy honesty — our differentiator (static product facts, not whale-specific) */}
      <div className="wp-section wp-trust">
        <div className="wp-section-title">🔒 {lang === 'ru' ? 'Честность копии' : 'Copy honesty'}</div>
        <div className="wp-trust-row">
          <span>{lang === 'ru' ? 'Копируем по ТВОЕЙ реальной цене, не китовой' : 'We copy at YOUR real fill price, not the whale’s'}</span>
        </div>
        <div className="wp-trust-row">
          <span>{lang === 'ru' ? 'Средний лаг копии' : 'Average copy lag'}</span><b>~12{lang === 'ru' ? 'с' : 's'}</b>
        </div>
        <div className="wp-trust-row">
          <span>Custody</span><b>{lang === 'ru' ? 'Non-custodial · не выводим' : 'Non-custodial · we can’t withdraw'}</b>
        </div>
      </div>

      {/* Top Markets */}
      {topMarkets.length > 0 && (
        <div className="wp-section">
          <div className="wp-section-title">Top Markets</div>
          <div className="wp-markets">
            {topMarkets.map((m) => (
              <div
                key={m.marketId}
                className="wp-market-row"
                onClick={() => navigate(`/market/${m.marketId}`)}
              >
                <div className="wp-market-q">{m.marketQuestion || m.marketId.slice(0, 12)}</div>
                <div className="wp-market-meta">
                  <span>{m.trades} trade{m.trades !== 1 ? 's' : ''}</span>
                  <span className="wp-market-vol">{formatVolume(m.volume)}</span>
                </div>
              </div>
            ))}
          </div>
        </div>
      )}

      {/* Recent Trades */}
      {trades.length > 0 && (
        <div className="wp-section">
          <div className="wp-section-title">Recent Trades</div>
          <div className="wp-trades">
            {trades.map((t) => {
              const isBuy = t.side === 'BUY';
              const roi = t.pnl != null && t.amount > 0 ? Math.round((t.pnl / t.amount) * 100) : null;
              return (
                <div
                  key={t.id}
                  className="wp-trade-row"
                  onClick={() => navigate(`/market/${t.marketId}`)}
                >
                  <div className="wp-trade-left">
                    <span className={isBuy ? 'wp-buy' : 'wp-sell'}>
                      {isBuy ? 'BUY' : 'SELL'}
                    </span>
                    <span className={t.outcome === 'Yes' ? 'wp-out-yes' : 'wp-out-no'}>
                      {t.outcome}
                    </span>
                    <span className="wp-trade-q">{t.marketQuestion || 'Unknown'}</span>
                  </div>
                  <div className="wp-trade-right">
                    {t.status === 'won' && (
                      <span className="wp-pnl wp-pnl-win">
                        🟢 +{formatVolume(t.pnl ?? 0)}{roi != null && ` · +${roi}%`}
                      </span>
                    )}
                    {t.status === 'lost' && (
                      <span className="wp-pnl wp-pnl-loss">
                        🔴 −{formatVolume(Math.abs(t.pnl ?? 0))} · −100%
                      </span>
                    )}
                    {t.status === 'open' && (
                      <span className="wp-pnl wp-pnl-open">{tr('wp.inPlay')}</span>
                    )}
                    <span className="wp-trade-amt">{formatVolume(t.amount)}</span>
                    <span className="wp-trade-price">{tr('wp.entryC', { c: Math.round(t.price * 100) })}</span>
                    <span className="wp-trade-time">{timeAgo(t.timestamp)}</span>
                  </div>
                </div>
              );
            })}
          </div>
          {totalPages > 1 && (
            <div className="wp-pager">
              <button
                className="wp-pager-btn"
                disabled={page === 0 || loading}
                onClick={() => setPage(p => Math.max(0, p - 1))}
              >
                {tr('mkt.back')}
              </button>
              <span className="wp-pager-info">{tr('wp.page', { p: page + 1, n: totalPages })}</span>
              <button
                className="wp-pager-btn"
                disabled={page >= totalPages - 1 || loading}
                onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
              >
                {tr('mkt.next')}
              </button>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

interface EqPoint {
  t: string;
  pnl: number;
  cum: number;
}

/** Cumulative realized-P&L curve over the whale's FULL resolved history (backend
 *  /equity aggregate, not paginated), with time (x) and money (y) axes. Hidden
 *  when too few resolved trades to be meaningful. */
function EquityChart({ equity }: { equity: EqPoint[] }) {
  const { t: tr } = useT();
  if (!equity || equity.length < 3) return null;

  const W = 340, H = 140;
  const mL = 48, mR = 10, mT = 10, mB = 22;       // margins for y-labels (left) / x-labels (bottom)
  const iw = W - mL - mR, ih = H - mT - mB;

  const times = equity.map(p => new Date(p.t + 'Z').getTime());
  const cums = equity.map(p => p.cum);
  const t0 = times[0], t1 = times[times.length - 1];
  const tRange = (t1 - t0) || 1;
  const minV = Math.min(0, ...cums);
  const maxV = Math.max(0, ...cums);
  const vRange = (maxV - minV) || 1;

  const x = (tm: number) => mL + ((tm - t0) / tRange) * iw;
  const y = (v: number) => mT + (1 - (v - minV) / vRange) * ih;

  const path = equity.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(times[i]).toFixed(1)},${y(p.cum).toFixed(1)}`).join(' ');
  const zeroY = y(0);
  const area = `${path} L${x(t1).toFixed(1)},${zeroY.toFixed(1)} L${x(t0).toFixed(1)},${zeroY.toFixed(1)} Z`;
  const last = cums[cums.length - 1];
  const up = last >= 0;
  const color = up ? '#16a34a' : '#dc2626';

  const fmtMoney = (v: number) => `${v < 0 ? '−' : ''}${formatVolume(Math.abs(v))}`;
  const fmtDate = (ms: number) => new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  const tMid = t0 + tRange / 2;

  return (
    <div className="wp-section">
      <div className="wp-section-title">{tr('wp.pnlDynamics')}</div>
      <div className="wp-equity">
        <svg viewBox={`0 0 ${W} ${H}`} className="wp-equity-svg">
          {/* Money axis: max / 0 / min gridlines + $ labels */}
          {[maxV, 0, minV].map((v, i) => (
            <g key={i}>
              <line x1={mL} y1={y(v)} x2={W - mR} y2={y(v)}
                stroke="var(--border)" strokeWidth="1"
                strokeDasharray={v === 0 ? undefined : '3 3'} opacity={v === 0 ? 0.85 : 0.4} />
              <text x={mL - 6} y={y(v) + 3} textAnchor="end" fontSize="9" fill="var(--text-muted)">{fmtMoney(v)}</text>
            </g>
          ))}
          {/* P&L curve + subtle fill */}
          <path d={area} fill={color} opacity="0.08" />
          <path d={path} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
          {/* Time axis: start / mid / end date labels */}
          <text x={mL} y={H - 6} textAnchor="start" fontSize="9" fill="var(--text-muted)">{fmtDate(t0)}</text>
          <text x={mL + iw / 2} y={H - 6} textAnchor="middle" fontSize="9" fill="var(--text-muted)">{fmtDate(tMid)}</text>
          <text x={W - mR} y={H - 6} textAnchor="end" fontSize="9" fill="var(--text-muted)">{fmtDate(t1)}</text>
        </svg>
        <div className="wp-equity-meta">
          <span className="wp-equity-val" style={{ color }}>
            {up ? '+' : '−'}{formatVolume(Math.abs(last))}
          </span>
          <span className="wp-equity-cap">{tr('wp.byResolved', { n: equity.length })}</span>
        </div>
      </div>
    </div>
  );
}

function StatBox({ label, value, tone }: { label: string; value: string; tone?: 'profit' | 'loss' }) {
  return (
    <div className="wp-stat">
      <span className="wp-stat-label">{label}</span>
      <span className="wp-stat-value" style={tone ? { color: tone === 'profit' ? 'var(--profit)' : 'var(--loss)' } : undefined}>{value}</span>
    </div>
  );
}

function formatDate(iso: string): string {
  try {
    const d = new Date(iso + 'Z');
    return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
  } catch {
    return iso.slice(0, 10);
  }
}




📜 Git History

bfc6c99feat(whale-profile): add P&L + derived Max Drawdown stats + Copy-honesty trust block12 days ago
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...