// portfolio-screen.jsx — the full Portfolio screen, themeable
// Exports to window: PortfolioScreen, THEMES
// Правки относительно дизайн-исходника (минимальные):
// • PortfolioScreen принимает props data/onRefresh (живые данные + реальный refresh)
// • RangeBar считает позицию маркера из реальной цены пула; V2 (без диапазона) скрыт
// • группы рендерятся только если в них есть данные; строка «Долг» — только если есть
const { useState: useStateS, useEffect: useEffectS } = React;
// ---------------- themes ----------------
const THEMES = {
slate: {
name: "A · Слейт",
desc: "Тёмная · приглушённый слейт-акцент · сводка сеткой",
layout: "grid",
dark: true,
vars: {
"--bg": "#0d1117",
"--card": "#161b22",
"--card2": "#1b2230",
"--border": "#2b3441",
"--border-soft": "#21262d",
"--text": "#e6edf3",
"--muted": "#8b949e",
"--dim": "#5e6772",
"--accent": "oklch(0.70 0.045 250)",
"--accent-soft": "oklch(0.70 0.045 250 / 0.16)",
"--pos": "#3fb950",
"--neg": "#f85149",
"--pos-soft": "rgba(63,185,80,0.14)",
"--neg-soft": "rgba(248,81,73,0.14)",
"--pos-line": "rgba(63,185,80,0.30)",
"--neg-line": "rgba(248,81,73,0.32)",
"--shadow": "none"
},
font: "ui-sans-serif, -apple-system, 'Segoe UI', system-ui, sans-serif",
radius: 12
},
taupe: {
name: "B · Биржа",
desc: "Тёмная almost-black · белые тикеры · серые полосы",
layout: "hero",
dark: true,
vars: {
"--bg": "#0b0e11",
"--card": "#181a20",
"--card2": "#1e2329",
"--border": "#2b3139",
"--border-soft": "#232830",
"--text": "#eaecef",
"--muted": "#848e9c",
"--dim": "#5e6673",
"--accent": "#eaecef",
"--accent-soft": "rgba(132,142,156,0.18)",
"--bar": "#5e6673",
"--pos": "#0ecb81",
"--neg": "#f6465d",
"--pos-soft": "rgba(14,203,129,0.14)",
"--neg-soft": "rgba(246,70,93,0.14)",
"--pos-line": "rgba(14,203,129,0.32)",
"--neg-line": "rgba(246,70,93,0.34)",
"--shadow": "none"
},
font: "ui-sans-serif, -apple-system, 'Segoe UI', system-ui, sans-serif",
radius: 14
},
sage: {
name: "C · Шалфей",
desc: "Светлая тема · серо-зелёный акцент · воздушно",
layout: "airy",
dark: false,
vars: {
"--bg": "#f5f7f8",
"--card": "#ffffff",
"--card2": "#f0f3f4",
"--border": "#e2e7e9",
"--border-soft": "#eef1f2",
"--text": "#1b2630",
"--muted": "#5d6c76",
"--dim": "#94a0a8",
"--accent": "oklch(0.55 0.045 168)",
"--accent-soft": "oklch(0.55 0.045 168 / 0.12)",
"--pos": "oklch(0.54 0.13 150)",
"--neg": "oklch(0.55 0.18 28)",
"--pos-soft": "oklch(0.54 0.13 150 / 0.12)",
"--neg-soft": "oklch(0.55 0.18 28 / 0.12)",
"--pos-line": "oklch(0.54 0.13 150 / 0.28)",
"--neg-line": "oklch(0.55 0.18 28 / 0.30)",
"--shadow": "0 1px 2px rgba(20,40,60,0.04), 0 4px 16px rgba(20,40,60,0.05)"
},
font: "ui-sans-serif, -apple-system, 'Segoe UI', system-ui, sans-serif",
radius: 16
},
exchangeLight: {
name: "B · Биржа · светлая",
desc: "Светлая тема · нейтральные тикеры · серые полосы · крупная цифра PnL",
layout: "hero",
dark: false,
vars: {
"--bg": "#f5f7fa",
"--card": "#ffffff",
"--card2": "#f0f2f5",
"--border": "#e6e8ec",
"--border-soft": "#eef0f3",
"--text": "#1e2026",
"--muted": "#707a8a",
"--dim": "#aeb4bf",
"--accent": "#1e2026",
"--accent-soft": "rgba(112,122,138,0.16)",
"--bar": "#aeb4bf",
"--pos": "#0a9e63",
"--neg": "#d6304a",
"--pos-soft": "rgba(10,158,99,0.12)",
"--neg-soft": "rgba(214,48,74,0.12)",
"--pos-line": "rgba(10,158,99,0.28)",
"--neg-line": "rgba(214,48,74,0.30)",
"--shadow": "0 1px 2px rgba(30,32,38,0.04), 0 4px 16px rgba(30,32,38,0.05)"
},
font: "ui-sans-serif, -apple-system, 'Segoe UI', system-ui, sans-serif",
radius: 14
}
};
// ---------------- helpers ----------------
function agoText(ms) {
const s = Math.floor((Date.now() - ms) / 1000);
if (s < 60) return "только что";
const m = Math.floor(s / 60);
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
return `${h} ч назад`;
}
// ---------------- small pieces ----------------
function RefreshBtn({ onClick, spinning }) {
return (
);
}
function Header({ updatedAt, onRefresh, layout, headerExtra }) {
const [, force] = useStateS(0);
const [spin, setSpin] = useStateS(false);
const [ts, setTs] = useStateS(updatedAt);
useEffectS(() => {
const t = setInterval(() => force((x) => x + 1), 15000);
return () => clearInterval(t);
}, []);
// синхронизируем отметку времени при приходе новых данных от родителя
useEffectS(() => { setTs(updatedAt); }, [updatedAt]);
const refresh = () => {
setSpin(true);
if (onRefresh) onRefresh(); // реальный refetch
setTimeout(() => {
setTs(Date.now());
setSpin(false);
}, 750);
};
return (
Портфель
обновлено {agoText(ts)}
{headerExtra}
);
}
// ---- Summary layouts ----
function Stat({ label, children, sub, big, accentValue }) {
return (
{label}
{children}
{sub &&
{sub}
}
);
}
function SummaryGrid({ s, fx }) {
return (
{window.fmt.money(s.investedEur, s.cur, { dec: 0 })}
{window.fmt.money(s.valueUsd, "$", { dec: 0 })}
= 0 ? "var(--pos)" : "var(--neg)"} sub={
{window.fmt.pct(s.pnlPct, { sign: true })}
}>
{window.fmt.money(s.pnlEur, s.cur, { dec: 0, sign: true })}
{window.fmt.rate(fx)}
);
}
function SummaryHero({ s, fx }) {
return (
Прибыль / убыток
= 0 ? "var(--pos)" : "var(--neg)", fontVariantNumeric: "tabular-nums" }}>
{window.fmt.money(s.pnlEur, s.cur, { dec: 0, sign: true })}
= 0 ? "var(--pos)" : "var(--neg)" }}>
{window.fmt.pct(s.pnlPct, { sign: true })}
);
}
function MiniStat({ label, value, sub }) {
return (
{label}
{value}
{sub &&
{sub}
}
);
}
function SummaryAiry({ s, fx }) {
return (
Чистая прибыль
{s.curCode}/USD {window.fmt.rate(fx)}
= 0 ? "var(--pos)" : "var(--neg)", fontVariantNumeric: "tabular-nums" }}>
{window.fmt.money(s.pnlEur, s.cur, { dec: 0, sign: true })}
= 0 ? "var(--pos)" : "var(--neg)" }}>
{window.fmt.pct(s.pnlPct, { sign: true })}
{window.fmt.money(s.investedEur, s.cur, { dec: 0 })}
{window.fmt.money(s.valueUsd, "$", { dec: 0 })}
);
}
// ---- group header ----
function GroupHeader({ title, total, share }) {
return (
{title}
{share != null &&
{window.fmt.pct(share)}
}
{total != null &&
{window.fmt.money(total, "$", { dec: 0 })}
}
);
}
// ---- coin row (expandable) ----
function CoinRow({ c, first, last }) {
const [open, setOpen] = useStateS(false);
return (
{open &&
{window.fmt.money(c.pnl, "$", { dec: 0, sign: true })}
} />
}
);
}
function Chevron({ open }) {
return (
);
}
function Detail({ label, value }) {
return (
{label}
{value}
);
}
// ---- cash block ----
function CashBlock({ cash }) {
return (
{window.fmt.money(cash.total, "$", { dec: 0 })}
{cash.items.map((it) =>
{it.ticker}
{window.fmt.money(it.amount, "$", { dec: 0 })}
)}
);
}
// ---- LP card ----
// neutral бейдж для V2 (full-range, нет диапазона)
function FullRangeBadge() {
return (
полный диапазон
);
}
function LpCard({ lp }) {
// красная рамка только если V3 и реально вне диапазона
const danger = lp.isV3 && lp.inRange === false;
return (
{lp.pair}
{lp.venue} · LP #{lp.id}
{lp.isV3 ?
:
}
{/* range bar — только для V3 (concentrated liquidity) */}
{lp.isV3 &&
}
{/* composition */}
{lp.composition.map((t) =>
{t.ticker}
{window.fmt.qty(t.qty)}
{window.fmt.money(t.value, "$", { dec: 0 })}
{t.weight}%
)}
{/* metrics row */}
{window.fmt.pct(lp.il, { sign: true })}} />
{window.fmt.money(lp.pnl, "$", { dec: 0, sign: true })}} />
);
}
function RangeBar({ lp }) {
// Полоса: диапазон [rangeLow..rangeHigh] визуально занимает 12%..88%.
// Маркер — реальная позиция текущей цены пула внутри/около диапазона.
const lowL = 12, highL = 88;
let cur;
if (lp.rangeLow != null && lp.rangeHigh != null && lp.rangeHigh > lp.rangeLow && lp.curPrice != null) {
let frac = (lp.curPrice - lp.rangeLow) / (lp.rangeHigh - lp.rangeLow);
frac = Math.max(-0.16, Math.min(1.16, frac)); // допускаем небольшой выход за края
cur = Math.max(2, Math.min(98, lowL + frac * (highL - lowL)));
} else {
cur = lp.inRange === false ? 96 : 50;
}
return (
{window.fmt.price(lp.rangeLow)}
диапазон цен
{window.fmt.price(lp.rangeHigh)}
);
}
// ---- loans ----
function LoansBlock({ loans, total }) {
return (
{loans.map((l) =>
#{l.id}
{l.ticker}
залог: {l.collateral}
{window.fmt.money(l.amount, "$", { dec: 0 })}
)}
);
}
// ---- total block ----
function TotalBlock({ s, fx, dark }) {
return (
Итого
{s.debtUsd > 0 &&
}
PnL
= 0 ? "var(--pos)" : "var(--neg)" }}>
{window.fmt.money(s.pnlEur, s.cur, { dec: 0, sign: true })}
{window.fmt.pct(s.pnlPct, { sign: true })}
Курс {s.curCode}/USD
{window.fmt.rate(fx)}
);
}
function TotalRow({ label, value, faint, bold, neg }) {
return (
{label}
{value}
);
}
// ---------------- main screen ----------------
function PortfolioScreen({ theme = "slate", width = 390, headerExtra, data, onRefresh }) {
const t = THEMES[theme];
const d = data || window.PF; // живые данные; fallback на мок для офлайн-превью
const SummaryEl = t.layout === "hero" ? SummaryHero : t.layout === "airy" ? SummaryAiry : SummaryGrid;
const hasCash = d.cash && d.cash.total > 0;
return (
{/* coins group */}
{d.coins.length > 0 &&
a + c.value, 0)} share={d.coins.reduce((a, c) => a + c.share, 0)} />
{d.coins.map((c, i) =>
)}
}
{/* cash group */}
{hasCash &&
}
{/* LP group */}
{d.lp.length > 0 &&
a + l.value, 0)} share={d.lp.reduce((a, l) => a + l.share, 0)} />
{d.lp.map((lp) => )}
}
{/* loans group */}
{d.loans.length > 0 &&
}
);
}
Object.assign(window, { PortfolioScreen, THEMES });