// 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 });