← Back
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { usePrivy } from '@privy-io/react-auth';
import { createPublicClient, http, erc20Abi } from 'viem';
import { polygon } from 'viem/chains';
import { useT } from '../i18n/LanguageContext';
import { formatUsd, formatVolume, formatPrice, timeUntil } from '../utils/format';
import SubTabs from '../components/shared/SubTabs';
import PnlDashboard from '../components/portfolio/PnlDashboard';
import TradeHistory from '../components/portfolio/TradeHistory';
import CopyTradingPage from './CopyTradingPage';
import { usePortfolioActivity, usePortfolioStats } from '../hooks/usePortfolioHistory';
import { useCopySubscriptions } from '../hooks/useCopySubscriptions';
import { useLeaderHoldings } from '../hooks/useLeaderHoldings';
import { whaleCodename } from '../utils/whales';

interface Position {
  asset: string;          // CLOB outcome tokenId — key for the manual-close endpoint
  conditionId: string;
  title: string;
  slug: string;
  eventSlug: string;
  icon: string;
  outcome: string;
  outcomeIndex: number;
  size: number;
  avgPrice: number;
  curPrice: number;
  initialValue: number;
  currentValue: number;
  cashPnl: number;
  percentPnl: number;
  realizedPnl: number;
  redeemable: boolean;
  negRisk: boolean;
  endDate: string;
}

type PosTab = 'open' | 'redeemable';
type SortKey = 'currentValue' | 'cashPnl' | 'percentPnl' | 'size';
type Section = 'positions' | 'subscriptions' | 'history' | 'pnl';

export default function TradePage() {
  const navigate = useNavigate();
  const { authenticated, getAccessToken } = usePrivy();
  const { t, lang } = useT();

  // Copy-trade positions live in the user's OWN Polymarket deposit wallet (the order
  // maker), not the email-login EOA. Resolve it per-user from the backend so each user
  // sees their own portfolio (never a hardcoded/shared wallet).
  const [fetchedWallet, setFetchedWallet] = useState<string | undefined>(undefined);
  // Derive (not stored) so logout clears it without a synchronous setState in the effect.
  const portfolioAddress = authenticated ? fetchedWallet : undefined;
  useEffect(() => {
    if (!authenticated) return;
    let alive = true;
    (async () => {
      try {
        const token = await getAccessToken();
        if (!token) return;
        const r = await fetch('/api/copy/wallet', { headers: { authorization: `Bearer ${token}` } });
        const d = await r.json();
        if (alive) setFetchedWallet(d?.data?.depositWallet || undefined);
      } catch { /* leave undefined → empty portfolio */ }
    })();
    return () => { alive = false; };
  }, [authenticated, getAccessToken]);

  const [section, setSection] = useState<Section>('positions');
  const [positions, setPositions] = useState<Position[]>([]);
  // conditionId(lower) → the whale we ENTERED this position by (from our copy ledger;
  // Data API positions don't carry it). Lets each card show "вошли по этому киту".
  const [positionLeaders, setPositionLeaders] = useState<Record<string, string>>({});
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [tab, setTab] = useState<PosTab>('open');

  // Portfolio hooks (only active when on respective tab)
  const { trades, loading: histLoading, hasMore, loadMore } = usePortfolioActivity(
    section === 'history' ? portfolioAddress : undefined
  );
  const { stats: pnlStats, loading: pnlLoading } = usePortfolioStats(
    section === 'pnl' ? portfolioAddress : undefined
  );
  const [sortKey, setSortKey] = useState<SortKey>('currentValue');
  const [refreshKey, setRefreshKey] = useState(0);
  // Manual close: optimistic "closing" set (tokenIds), the position we're confirming, and any error.
  // The daemon (sole order signer) does the SELL/resolve on its next cycle (~15-30s); the card shows
  // "Закрываю…" until the position drops out of Data API (balance→0).
  const [closingIds, setClosingIds] = useState<Set<string>>(new Set());
  const [confirmClose, setConfirmClose] = useState<Position | null>(null);
  const [closeErr, setCloseErr] = useState<string | null>(null);
  const requestClose = async (pos: Position) => {
    setConfirmClose(null); setCloseErr(null);
    try {
      const token = await getAccessToken();
      if (!token) { setCloseErr(t('pf.closeErr')); return; }
      const r = await fetch(`/api/copy/positions/${encodeURIComponent(pos.asset)}/close`, {
        method: 'POST', headers: { authorization: `Bearer ${token}` },
      });
      const d = await r.json().catch(() => ({}));
      if (r.ok && d?.success) setClosingIds(prev => new Set(prev).add(pos.asset));
      else setCloseErr(d?.error || t('pf.closeErr'));
    } catch { setCloseErr(t('pf.closeErr')); }
  };
  const [cashRaw, setCashRaw] = useState(0);
  // Derived: shown as 0 when there's no wallet (no synchronous reset in the effect).
  const cashBalance = (authenticated && portfolioAddress) ? cashRaw : 0;

  // Followed leaders + their live holdings, to show "which whale holds how much"
  // on each copied position card (the slon you're riding).
  const { subs } = useCopySubscriptions();
  const activeLeaders = useMemo(
    () => subs.filter(s => s.status === 'active').map(s => s.address),
    [subs]
  );
  const leaderHoldings = useLeaderHoldings(activeLeaders, getAccessToken);

  // Wallet cash (pUSD) balance — refreshed with the positions.
  useEffect(() => {
    if (!authenticated || !portfolioAddress) return;
    const pub = createPublicClient({ chain: polygon, transport: http('https://polygon.drpc.org') });
    let cancelled = false;
    pub.readContract({ address: '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB', abi: erc20Abi, functionName: 'balanceOf', args: [portfolioAddress as `0x${string}`] })
      .then(b => { if (!cancelled) setCashRaw(Number(b) / 1e6); }).catch(() => {});
    return () => { cancelled = true; };
  }, [authenticated, portfolioAddress, refreshKey]);

  // Keep the portfolio current: refresh every 30s.
  useEffect(() => {
    const iv = setInterval(() => setRefreshKey(k => k + 1), 30000);
    return () => clearInterval(iv);
  }, []);

  useEffect(() => {
    if (!authenticated || !portfolioAddress) {
      const t = setTimeout(() => { setPositions([]); }, 0);
      return () => { clearTimeout(t); };
    }

    const timer = setTimeout(() => {
      setLoading(true);
      setError(null);

      const url = tab === 'open'
        ? `https://data-api.polymarket.com/positions?user=${portfolioAddress}&sizeThreshold=0.1&limit=100&sortBy=CURRENT&sortDirection=DESC`
        : `https://data-api.polymarket.com/positions?user=${portfolioAddress}&sizeThreshold=0&redeemable=true&limit=100`;

      fetch(url)
        .then(r => r.json())
        .then((data: Position[]) => {
          if (Array.isArray(data)) {
            setPositions(data);
          } else {
            setPositions([]);
          }
        })
        .catch(() => setError(t('pf.failedPositions')))
        .finally(() => setLoading(false));
    }, 0);
    return () => { clearTimeout(timer); };
  }, [authenticated, portfolioAddress, tab, refreshKey, t]);

  // Entry-leader map for the cards (which whale each open copy was opened by). Refreshed
  // alongside positions; keyed by conditionId. Needs the user's auth token (session/Privy).
  useEffect(() => {
    if (!authenticated || !portfolioAddress) { setPositionLeaders({}); return; }
    let cancelled = false;
    (async () => {
      try {
        const token = await getAccessToken();
        if (!token) return;
        const r = await fetch('/api/copy/position-leaders', { headers: { authorization: `Bearer ${token}` } });
        const d = await r.json();
        if (!cancelled && d?.success && d.data) setPositionLeaders(d.data as Record<string, string>);
      } catch { /* keep last good map */ }
    })();
    return () => { cancelled = true; };
  }, [authenticated, portfolioAddress, refreshKey, getAccessToken]);

  // Sort positions
  const sorted = useMemo(() =>
    [...positions].sort((a, b) => (b[sortKey] as number) - (a[sortKey] as number)),
    [positions, sortKey]
  );

  // Summary
  const { totalValue, totalPnl, totalPnlPct } = useMemo(() => {
    const tv = positions.reduce((sum, p) => sum + (p.currentValue || 0), 0);
    const tp = positions.reduce((sum, p) => sum + (p.cashPnl || 0), 0);
    const ti = positions.reduce((sum, p) => sum + (p.initialValue || 0), 0);
    return {
      totalValue: tv,
      totalPnl: tp,
      totalPnlPct: ti > 0 ? (tp / ti) * 100 : 0,
    };
  }, [positions]);

  if (!authenticated) {
    return (
      <div className="portfolio-page">
        <div className="portfolio-empty">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="40" height="40">
            <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
            <path d="M7 11V7a5 5 0 0110 0v4" />
          </svg>
          <h3>{t('pf.connectTitle')}</h3>
          <p>{t('pf.connectDesc')}</p>
        </div>
      </div>
    );
  }

  const sectionTabs = [
    { key: 'positions', label: t('pf.positions'), badge: positions.length || undefined },
    { key: 'subscriptions', label: t('pf.subs') },
    { key: 'history', label: t('pf.history') },
    { key: 'pnl', label: t('pf.pnl') },
  ];

  return (
    <div className="portfolio-page">
      <h2 className="portfolio-title">{t('pf.title')}</h2>

      {/* Top-level sub-tabs */}
      <SubTabs tabs={sectionTabs} active={section} onChange={k => setSection(k as Section)} />

      {/* === POSITIONS section === */}
      {section === 'positions' && (
        <>
          {/* Summary cards */}
          <div className="portfolio-summary">
            <div className="summary-card">
              <span className="summary-card-label">{t('trade.balance')}</span>
              <span className="summary-card-value">{formatUsd(cashBalance)}</span>
            </div>
            <div className="summary-card">
              <span className="summary-card-label">{t('pf.totalValue')}</span>
              <span className="summary-card-value">{formatUsd(totalValue)}</span>
            </div>
            <div className="summary-card">
              <span className="summary-card-label">{t('pf.totalPnl')}</span>
              <span className={`summary-card-value ${totalPnl >= 0 ? 'text-profit' : 'text-loss'}`}>
                {totalPnl >= 0 ? '+' : ''}{formatUsd(Math.abs(totalPnl))}
                <small> ({totalPnlPct >= 0 ? '+' : ''}{totalPnlPct.toFixed(1)}%)</small>
              </span>
            </div>
            <div className="summary-card">
              <span className="summary-card-label">{t('pf.positionsCount')}</span>
              <span className="summary-card-value">{positions.length}</span>
            </div>
          </div>

          {/* Open Positions */}
          <div className="portfolio-tabs">
            <button
              className={`portfolio-tab ${tab === 'open' ? 'portfolio-tab-active' : ''}`}
              onClick={() => setTab('open')}
            >
              {t('pf.openPositions')}
            </button>
          </div>

          {/* Sort */}
          <div className="portfolio-sort">
            <span className="sort-label">{t('pf.sortBy')}</span>
            {([
              ['currentValue', t('pf.value')],
              ['cashPnl', t('pf.pnl')],
              ['percentPnl', t('pf.pnlPct')],
              ['size', t('pf.size')],
            ] as [SortKey, string][]).map(([key, label]) => (
              <button
                key={key}
                className={`sort-btn ${sortKey === key ? 'sort-btn-active' : ''}`}
                onClick={() => setSortKey(key)}
              >
                {label}
              </button>
            ))}
          </div>

          {/* Content */}
          {loading && (
            <div className="portfolio-loading">
              {Array.from({ length: 4 }, (_, i) => (
                <div key={i} className="position-card">
                  <div className="skeleton-line" style={{ width: '60%', height: 16 }} />
                  <div className="skeleton-line" style={{ width: '40%', height: 14, marginTop: 8 }} />
                </div>
              ))}
            </div>
          )}

          {error && (
            <div className="portfolio-empty">
              <p>{error}</p>
            </div>
          )}

          {!loading && !error && sorted.length === 0 && (
            <div className="portfolio-empty">
              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="36" height="36">
                <path d="M21 12H3M21 12l-4-4M21 12l-4 4" />
              </svg>
              <p>{tab === 'open' ? t('pf.noOpen') : t('pf.nothingRedeem')}</p>
              <button className="page-btn" onClick={() => navigate('/screener')}>
                {t('pf.findMarkets')}
              </button>
            </div>
          )}

          {!loading && !error && sorted.length > 0 && (
            <div className="positions-list">
              {sorted.map(pos => (
                <div
                  key={`${pos.conditionId}-${pos.outcomeIndex}`}
                  className="position-card"
                  onClick={() => navigate(`/market/${pos.conditionId}`)}
                >
                  <div className="pos-header">
                    <div className="pos-title-row">
                      {pos.icon && (
                        <img src={pos.icon} alt="" className="pos-icon"
                          onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
                        />
                      )}
                      <div className="pos-title-text">
                        <span className="pos-title">{pos.title}</span>
                        <span className={`pos-outcome pos-outcome-${pos.outcome?.toLowerCase()}`}>
                          {pos.outcome || (pos.outcomeIndex === 0 ? 'YES' : 'NO')}
                        </span>
                      </div>
                    </div>
                  </div>

                  <div className="pos-stats">
                    <div className="pos-stat">
                      <span className="pos-stat-label">{t('pf.shares')}</span>
                      <span className="pos-stat-value">{pos.size?.toFixed(2)}</span>
                    </div>
                    <div className="pos-stat">
                      <span className="pos-stat-label">{t('pf.avgPrice')}</span>
                      <span className="pos-stat-value">{formatPrice(pos.avgPrice)}</span>
                    </div>
                    <div className="pos-stat">
                      <span className="pos-stat-label">{t('pf.current')}</span>
                      <span className="pos-stat-value">{formatPrice(pos.curPrice)}</span>
                    </div>
                    <div className="pos-stat">
                      <span className="pos-stat-label">{t('pf.value')}</span>
                      <span className="pos-stat-value">{formatUsd(pos.currentValue)}</span>
                    </div>
                  </div>

                  {/* The whale we ENTERED by (always shown), plus who else holds this market live. */}
                  {(() => {
                    const cid = (pos.conditionId || '').toLowerCase();
                    const entry = positionLeaders[cid];
                    const byAddr = new Map<string, { address: string; value: number }>();
                    for (const w of (leaderHoldings[cid] || [])) {
                      if (w.value > 0) byAddr.set(w.address.toLowerCase(), { address: w.address, value: w.value });
                    }
                    // Ensure the entry leader is always present, even if Data API shows no live holding.
                    if (entry && !byAddr.has(entry)) byAddr.set(entry, { address: entry, value: 0 });
                    const whales = [...byAddr.values()].sort((a, b) =>
                      a.address.toLowerCase() === entry ? -1
                        : b.address.toLowerCase() === entry ? 1
                          : b.value - a.value
                    );
                    if (!whales.length) return null;
                    return (
                      <div className="pos-whales" title={t('pf.whaleHint')}>
                        {whales.length > 1 && (
                          <span className="pos-whales-count">🐋 {t('pf.whalesIn', { n: whales.length })}</span>
                        )}
                        {whales.map(w => {
                          const isEntry = !!entry && w.address.toLowerCase() === entry;
                          return (
                            <div className={`pos-whale${isEntry ? ' pos-whale-entry' : ''}`} key={w.address}>
                              {whales.length === 1 ? '🐋 ' : '· '}{whaleCodename(w.address, lang)}
                              {isEntry && whales.length > 1 && (
                                <span className="pos-whale-badge" title={t('pf.entryLeaderHint')}>🎯 {t('pf.entryLeader')}</span>
                              )}
                              {w.value > 0 && (
                                <span className="pos-whale-amt">{t('pf.whaleHolds')} {formatVolume(w.value)}</span>
                              )}
                            </div>
                          );
                        })}
                      </div>
                    );
                  })()}

                  {/* Current implied probability of the held outcome + countdown */}
                  <div className="pos-prob">
                    <div className="pos-prob-bar">
                      <div
                        className="pos-prob-fill"
                        style={{ width: `${Math.round(Math.max(0, Math.min(100, (pos.curPrice || 0) * 100)))}%` }}
                      />
                    </div>
                    {pos.redeemable ? (
                      <span className="pos-ends">⌛ {t('time.ended')}</span>
                    ) : pos.endDate ? (
                      <span className="pos-ends">
                        {/* Past stated end but not redeemable = settlement pending or a stale/extended
                            end_date (e.g. long-shot markets). Show honest "resolving", not a fake "<1h". */}
                        ⏳ {timeUntil(pos.endDate) === t('time.ended')
                          ? t('time.resolving')
                          : timeUntil(pos.endDate)}
                      </span>
                    ) : null}
                  </div>

                  <div className="pos-pnl-row">
                    <span className="pos-stat-label">{t('pf.pnl')}</span>
                    <span className={(pos.cashPnl ?? 0) >= 0 ? 'text-profit' : 'text-loss'}>
                      {(pos.cashPnl ?? 0) >= 0 ? '+' : '-'}${Math.abs(pos.cashPnl ?? 0).toFixed(2)}
                      {' '}
                      <small>({(pos.percentPnl ?? 0) >= 0 ? '+' : ''}{(pos.percentPnl ?? 0).toFixed(1)}%)</small>
                    </span>
                  </div>

                  <div className="pos-actions">
                    {closingIds.has(pos.asset) ? (
                      <button className="pos-close-btn" disabled>{t('pf.closing')}</button>
                    ) : (
                      <button
                        className="pos-close-btn"
                        onClick={e => { e.stopPropagation(); setCloseErr(null); setConfirmClose(pos); }}
                      >
                        {pos.redeemable ? t('pf.redeemBtn') : t('pf.close')}
                      </button>
                    )}
                  </div>
                </div>
              ))}
            </div>
          )}
        </>
      )}

      {/* === SUBSCRIPTIONS section (бывш. /copy «Мои копии») === */}
      {section === 'subscriptions' && (
        <CopyTradingPage />
      )}

      {/* === P&L section === */}
      {section === 'pnl' && (
        <PnlDashboard stats={pnlStats} loading={pnlLoading} />
      )}

      {/* === HISTORY section === */}
      {section === 'history' && (
        <TradeHistory
          trades={trades}
          loading={histLoading}
          hasMore={hasMore}
          onLoadMore={loadMore}
        />
      )}

      {closeErr && (
        <div className="close-toast" onClick={() => setCloseErr(null)}>{closeErr}</div>
      )}

      {/* Manual-close confirmation. Estimate = shares × current price (mark). Honest warning:
          actual fill on a thin book can be lower. Resolved markets pay out via auto-redeem. */}
      {confirmClose && (
        <div className="modal-backdrop" onClick={() => setConfirmClose(null)}>
          <div className="modal-card" onClick={e => e.stopPropagation()}>
            <h3 className="modal-title">
              {confirmClose.redeemable ? t('pf.redeemConfirmTitle') : t('pf.closeConfirmTitle')}
            </h3>
            <p className="modal-market">{confirmClose.title}</p>
            <div className="modal-row">
              <span>{t('pf.shares')}</span><span>{confirmClose.size?.toFixed(2)}</span>
            </div>
            {!confirmClose.redeemable && (
              <div className="modal-row">
                <span>{t('pf.closeEst')}</span>
                <span>≈ {formatUsd((confirmClose.size || 0) * (confirmClose.curPrice || 0))}</span>
              </div>
            )}
            <p className="modal-warn">
              {confirmClose.redeemable ? t('pf.redeemConfirmDesc') : t('pf.closeConfirmDesc')}
            </p>
            <div className="modal-actions">
              <button className="page-btn" onClick={() => setConfirmClose(null)}>{t('pf.cancel')}</button>
              <button className="page-btn page-btn-primary" onClick={() => requestClose(confirmClose)}>
                {confirmClose.redeemable ? t('pf.redeemBtn') : t('pf.close')}
              </button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

📜 Git History

6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...