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';
import '../redesign/editorial.css';
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 [positionDirections, setPositionDirections] = 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>); setPositionDirections((d.directions || {}) 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 pk-portfolio">
<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 pk-portfolio">
<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>
{positionDirections[(pos.conditionId || '').toLowerCase()] === 'fade' && (
<span style={{ color: '#ff7a7a', fontWeight: 700, fontSize: '0.7rem', marginLeft: 6 }}>💀 FADE</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; initial: number }>();
for (const w of (leaderHoldings[cid] || [])) {
// initial = whale's entry bet; survives market resolution (value goes 0 when resolved).
if (w.initial > 0) byAddr.set(w.address.toLowerCase(), { address: w.address, value: w.value, initial: w.initial });
}
// 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, initial: 0 });
const whales = [...byAddr.values()].sort((a, b) =>
a.address.toLowerCase() === entry ? -1
: b.address.toLowerCase() === entry ? 1
: b.initial - a.initial
);
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.initial > 0 && (
<span className="pos-whale-amt">{lang === 'ru' ? 'зашёл' : 'bet'} {formatVolume(w.initial)}</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}
directions={positionDirections}
/>
)}
{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
b9c19bfchore(poli): reconcile local Flow/Insider/manual-trade work with deployed state3 days ago
44f0309feat(poli): editorial Portfolio (My Trades) reskin (chunk 5)11 days ago
6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...