PolyTools Trade Cost 0.1.1 / src/content/panel.js
| 1 | /* |
| 2 | * PolyTools — the trade-cost panel (Shadow DOM, no innerHTML with data). |
| 3 | * create({onAction}) → { mountAfter(el), mountFloat(), hide(), render(vm), setTheme(dark), host } |
| 4 | */ |
| 5 | (function (root) { |
| 6 | 'use strict'; |
| 7 | |
| 8 | const CSS = ` |
| 9 | :host { all: initial; } |
| 10 | .pwl { --bg:#ffffff; --surface:#fcfcfb; --ink:#0b0b0b; --ink2:#52514e; --muted:#6b6a65; --line:rgba(11,11,11,0.10); |
| 11 | --pos:#006300; --neg:#c02f2f; --accent:#2a78d6; --link:#1f66bd; --tip-bg:rgba(42,120,214,0.08); --warn-bg:rgba(250,178,25,0.14); --warn:#8a5a00; |
| 12 | font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif; color: var(--ink); |
| 13 | background: var(--bg); border: 1px solid var(--line); border-radius: 14px; box-sizing: border-box; |
| 14 | width: 100%; margin: 12px 0 0; overflow: hidden; } |
| 15 | .pwl.dark { --bg:#1c1f27; --surface:#181b22; --ink:#f0efec; --ink2:#c3c2b7; --muted:#9a988f; --line:rgba(255,255,255,0.10); |
| 16 | --pos:#2fbf4f; --neg:#ef7b7b; --accent:#3987e5; --link:#6ea8f0; --tip-bg:rgba(57,135,229,0.14); --warn-bg:rgba(250,178,25,0.16); --warn:#fab219; } |
| 17 | .pwl.float { position: fixed; right: 16px; bottom: 16px; width: 340px; max-width: calc(100vw - 32px); z-index: 2147483000; margin: 0; |
| 18 | box-shadow: 0 8px 28px rgba(0,0,0,0.18); max-height: 80vh; max-height: 80dvh; overflow: auto; } |
| 19 | /* minimized floating mode (no ticket on screen: narrow layouts): a small round button that covers nothing important */ |
| 20 | /* right edge at ~42% height: on narrow market pages that is the price chart (no controls); the site's sticky |
| 21 | Yes/No bar, tab bar and section headers stay free (checked at 390, 900 and 1000 px wide on 2026-10-28) */ |
| 22 | .pwl.float.min { width: auto; left: auto; right: 10px; top: 42vh; bottom: auto; border-radius: 999px; overflow: visible; box-shadow: 0 4px 14px rgba(0,0,0,0.22); } |
| 23 | .pill { display:flex; align-items:center; justify-content:center; width:44px; height:44px; border:0; border-radius:999px; background: var(--bg); padding:0; } |
| 24 | .pill img { width:26px; height:26px; } |
| 25 | .summary { padding: 0 12px 10px; color: var(--ink2); font-size:12px; font-variant-numeric: tabular-nums; } |
| 26 | * { box-sizing: border-box; } |
| 27 | header { display:flex; align-items:center; gap:8px; padding:10px 12px 8px; } |
| 28 | .logo { width:18px; height:18px; flex:none; } |
| 29 | .brand { font-weight:600; font-size:13px; } |
| 30 | .title { color: var(--ink2); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:1; } |
| 31 | button { font: inherit; color: inherit; cursor: pointer; } |
| 32 | .icon-btn { border:0; background:transparent; color: var(--muted); padding:6px 9px; min-width:30px; min-height:30px; border-radius:8px; font-size:16px; line-height:1; } |
| 33 | .icon-btn:hover { background: var(--line); color: var(--ink); } |
| 34 | .ctx { display:flex; align-items:center; gap:6px; padding:0 12px 8px; color: var(--ink2); font-size:12px; flex-wrap:wrap; } |
| 35 | .ctx b { color: var(--ink); font-weight:600; } |
| 36 | .dot { width:6px; height:6px; border-radius:50%; background: var(--pos); flex:none; } |
| 37 | .dot.off { background: var(--muted); } |
| 38 | .sync { color: var(--muted); font-size:11px; margin-left:auto; display:flex; align-items:center; gap:4px; } |
| 39 | .hero { padding:10px 12px; border-top:1px solid var(--line); border-bottom:1px solid var(--line); background: var(--surface); } |
| 40 | .hero-label { font-size:11px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } |
| 41 | .hero-row { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; margin-top:2px; } |
| 42 | .hero-val { font-size:22px; font-weight:650; } |
| 43 | .hero-sub { color: var(--ink2); font-size:13px; } |
| 44 | .pos { color: var(--pos); } .neg { color: var(--neg); } |
| 45 | .rows { padding:6px 12px 4px; } |
| 46 | .row { display:flex; justify-content:space-between; gap:12px; padding:4px 0; } |
| 47 | .row .k { color: var(--ink2); flex: 0 1 auto; max-width: 55%; } |
| 48 | .row .v { text-align:right; font-variant-numeric: tabular-nums; flex: 1 1 0; min-width: 0; } |
| 49 | .row .v small { display:block; color: var(--muted); font-size:11px; } |
| 50 | .note { margin: 6px 12px; padding:8px 10px; border-radius:10px; font-size:12px; display:flex; gap:8px; } |
| 51 | .note .ic { flex:none; } |
| 52 | .tip { background: var(--tip-bg); } |
| 53 | .warn { background: var(--warn-bg); } |
| 54 | .msg { padding: 10px 12px; color: var(--ink2); } |
| 55 | .foot { display:flex; align-items:center; gap:10px; padding:8px 12px 10px; color: var(--muted); font-size:11px; border-top:1px solid var(--line); margin-top:6px; flex-wrap:wrap; } |
| 56 | .linkbtn { border:0; background:none; padding:0; color: var(--link); font: inherit; font-size:11px; cursor:pointer; } |
| 57 | .linkbtn:hover { text-decoration:underline; } |
| 58 | .foot .live { color: var(--pos); } |
| 59 | button:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } |
| 60 | .manual { padding: 0 12px 8px; display:grid; gap:6px; } |
| 61 | .manual label { font-size:11px; color: var(--muted); display:block; margin-bottom:2px; } |
| 62 | select, input { font: inherit; color: var(--ink); background: var(--bg); border:1px solid var(--line); border-radius:8px; padding:6px 8px; width:100%; } |
| 63 | .seg { display:flex; gap:4px; flex-wrap:wrap; } |
| 64 | .seg button { flex:1; border:1px solid var(--line); background: var(--bg); border-radius:8px; padding:5px 6px; font-size:12px; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } |
| 65 | .seg button.on { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); font-weight:600; } |
| 66 | .collapsed .body { display:none; } |
| 67 | .disc { color: var(--muted); font-size:10.5px; padding: 0 12px 10px; } |
| 68 | `; |
| 69 | |
| 70 | // PolyTools mark (same artwork as polytools.vip and the toolbar icon) |
| 71 | const LOGO = 'data:image/svg+xml;utf8,' + encodeURIComponent( |
| 72 | '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#0d1119"/>' + |
| 73 | '<g transform="translate(3.2 1.3) scale(.8)"><path fill="#e8b65a" d="M12.9 25.4C13.6 22 14.1 18.6 14.3 15.5 11.4 15.6 7.5 14.8 4.9 12.6 ' + |
| 74 | '3.5 11.4 2.7 9.4 2.5 7.2 5.3 9 8.9 9.6 11.9 9.6c1.8 0 3.3.6 4.1 1.8.8-1.2 2.3-1.8 4.1-1.8 3 0 6.6-.6 9.4-2.4-.2 2.2-1 4.2-2.4 5.4' + |
| 75 | '-2.6 2.2-6.5 3-9.4 2.9.2 3.1.7 6.5 1.4 9.9z"/><path fill="none" stroke="#e8b65a" stroke-width="1.6" stroke-linecap="round" ' + |
| 76 | 'd="M3.5 28.4c1.7 0 2.5-1.1 4.2-1.1s2.5 1.1 4.2 1.1 2.5-1.1 4.1-1.1 2.5 1.1 4.2 1.1 2.5-1.1 4.2-1.1 2.5 1.1 4.1 1.1"/></g></svg>'); |
| 77 | |
| 78 | function h(tag, attrs, ...kids) { |
| 79 | const el = document.createElement(tag); |
| 80 | if (attrs) for (const [k, v] of Object.entries(attrs)) { |
| 81 | if (v == null || v === false) continue; |
| 82 | if (k === 'class') el.className = v; |
| 83 | else if (k === 'text') el.textContent = v; |
| 84 | else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); |
| 85 | else el.setAttribute(k, v === true ? '' : v); |
| 86 | } |
| 87 | for (const kid of kids.flat()) if (kid != null && kid !== false) el.append(kid instanceof Node ? kid : document.createTextNode(String(kid))); |
| 88 | return el; |
| 89 | } |
| 90 | |
| 91 | function create(opts) { |
| 92 | const { i18n, fmt } = root.PWL; |
| 93 | const journal = !root.PWL.flavor || root.PWL.flavor.journal; // the Trade Cost build has no journal page |
| 94 | const t = i18n.t; |
| 95 | const host = document.createElement('div'); |
| 96 | host.id = 'pwl-lens-host'; |
| 97 | host.setAttribute('data-pwl', '1'); |
| 98 | const shadow = host.attachShadow({ mode: 'open' }); |
| 99 | shadow.append(h('style', { text: CSS })); |
| 100 | const box = h('div', { class: 'pwl' }); |
| 101 | shadow.append(box); |
| 102 | let dark = false, floating = false, collapsed = false; |
| 103 | const act = (type, payload) => opts.onAction && opts.onAction(type, payload); |
| 104 | |
| 105 | function mountAfter(anchor) { |
| 106 | floating = false; |
| 107 | box.classList.remove('float'); |
| 108 | if (anchor && anchor.parentElement && anchor.nextSibling !== host) anchor.after(host); |
| 109 | host.style.display = ''; |
| 110 | } |
| 111 | function mountFloat() { |
| 112 | floating = true; |
| 113 | box.classList.add('float'); |
| 114 | if (host.parentElement !== document.body) document.body.append(host); |
| 115 | host.style.display = ''; |
| 116 | } |
| 117 | function hide() { host.style.display = 'none'; } |
| 118 | function setTheme(isDark) { if (isDark !== dark) { dark = isDark; box.classList.toggle('dark', dark); } } |
| 119 | function setCollapsed(c) { collapsed = !!c; box.classList.toggle('collapsed', collapsed); } |
| 120 | |
| 121 | // Annualizing a long-shot payoff (ROI > 100%) produces meaningless numbers — only show APR for "bond-like" trades. |
| 122 | // ... and only when the horizon is at least a week (shorter ones turn +3% into +5,000%/yr). |
| 123 | const aprShown = (r) => r && r.apr && r.apr.simple != null && r.apr.reliable && !r.expired && r.roi != null && r.roi <= 1; |
| 124 | const signClass = (x) => (x > 0.0000001 ? 'pos' : x < -0.0000001 ? 'neg' : ''); |
| 125 | const row = (k, v, sub, cls) => h('div', { class: 'row' }, h('span', { class: 'k', text: k }), |
| 126 | h('span', { class: 'v' + (cls ? ' ' + cls : '') }, v, sub ? h('small', { text: sub }) : null)); |
| 127 | const note = (kind, icon, text) => h('div', { class: 'note ' + kind, role: kind === 'warn' ? 'status' : null }, |
| 128 | h('span', { class: 'ic', 'aria-hidden': 'true', text: icon }), h('span', { text })); |
| 129 | |
| 130 | function feeRow(r, feeModel) { |
| 131 | if (r.fee > 0) { |
| 132 | return row(t('panel.fee'), fmt.money(r.fee), [fmt.pct(r.feePct), |
| 133 | r.feeShareOfProfit != null ? t('panel.feeOfProfit', { pct: fmt.pct(r.feeShareOfProfit) }) : null].filter(Boolean).join(' · ')); |
| 134 | } |
| 135 | return row(t('panel.fee'), '$0', feeModel.type === 'zero_fees' ? t('panel.feePromo') : !feeModel.rate ? t('panel.feeNone') : null); |
| 136 | } |
| 137 | |
| 138 | function horizonRows(r, vm) { |
| 139 | const out = []; |
| 140 | if (r.days != null) { |
| 141 | out.push(row(t('panel.until'), r.expired ? t('panel.endPassed') : fmt.duration(r.days, t), vm.endDateText)); |
| 142 | } |
| 143 | if (aprShown(r)) { |
| 144 | out.push(row(t('panel.apr'), fmt.pct(r.apr.simple, null, true), null, signClass(r.apr.simple))); |
| 145 | } else if (r.apr && r.apr.simple != null && !r.apr.reliable && !r.expired && r.roi != null && r.roi <= 1) { |
| 146 | out.push(row(t('panel.apr'), fmt.NA, t('panel.aprShort'))); |
| 147 | } |
| 148 | if (r.holdingUsd > 0) { |
| 149 | out.push(row(t('panel.holding', { rate: fmt.pct(r.holdingRate || vm.holdingRate) }), '+' + fmt.money(r.holdingUsd), |
| 150 | r.aprWithHolding && r.aprWithHolding.simple != null && aprShown(r) |
| 151 | ? t('panel.withHolding', { roi: fmt.pct(r.roiWithHolding, null, true), apr: fmt.pct(r.aprWithHolding.simple, null, true) }) |
| 152 | : t('panel.holdingVal', { usd: fmt.money(r.holdingUsd) }))); |
| 153 | } |
| 154 | return out; |
| 155 | } |
| 156 | |
| 157 | function warnings(list, r) { |
| 158 | return list.map((w) => { |
| 159 | const vars = { |
| 160 | usd: fmt.money(r.unfilledUsd), n: fmt.number(w === 'belowMinSize' ? r.minSize : r.unfilledShares, 2), |
| 161 | pct: fmt.pct(w === 'feeEatsProfit' ? r.feeShareOfProfit : r.slippage), b: fmt.pct(r.benchmarkApr), |
| 162 | }; |
| 163 | const key = w === 'unfilled' && r.kind === 'sell' ? 'warn.unfilledShares' : 'warn.' + w; |
| 164 | return note('warn', '⚠', t(key, vars)); |
| 165 | }); |
| 166 | } |
| 167 | |
| 168 | /** Nothing to fill (empty side of the book, nothing at this size): show the warning only. */ |
| 169 | function emptyBody(r, vm) { |
| 170 | return warnings(r.warnings.length ? r.warnings : ['noFill'], Object.assign({ benchmarkApr: vm.benchmarkApr }, r)); |
| 171 | } |
| 172 | |
| 173 | function buyBody(r, vm) { |
| 174 | if (r.noLiquidity || !(r.shares > 0)) return emptyBody(r, vm); |
| 175 | const parts = []; |
| 176 | parts.push(h('div', { class: 'hero' }, |
| 177 | h('div', { class: 'hero-label', text: t('panel.ifWins') }), |
| 178 | h('div', { class: 'hero-row' }, |
| 179 | h('span', { class: 'hero-val ' + signClass(r.profit), text: fmt.money(r.profit, { sign: true }) }), |
| 180 | h('span', { class: 'hero-sub', text: fmt.pct(r.roi, null, true) + (aprShown(r) ? ' · ' + fmt.pct(r.apr.simple, null, true) + t('panel.perYear') : '') })))); |
| 181 | const rows = h('div', { class: 'rows' }); |
| 182 | rows.append( |
| 183 | row(t('panel.avgPrice'), fmt.cents(r.avgPrice), [t('panel.bestPrice', { price: fmt.cents(r.bestAsk) }), |
| 184 | r.fee > 0 ? t('panel.withFee', { price: fmt.cents(r.totalCost / r.shares, 2) }) : null].filter(Boolean).join(' · ')), |
| 185 | row(t('panel.slippage'), fmt.pct(r.slippage), fmt.money(r.slippageUsd) + ' · ' + t('panel.levelsN', { n: r.levels })), |
| 186 | feeRow(r, vm.feeModel), |
| 187 | row(t('panel.totalCost'), fmt.money(r.totalCost), r.allIn |
| 188 | ? t('panel.ofAmount', { usd: fmt.money(r.usd), n: fmt.number(r.shares, 2) }) |
| 189 | : fmt.number(r.shares, 2) + ' × $1'), |
| 190 | row(t('panel.payout'), fmt.money(r.payout)), |
| 191 | row(t('panel.breakeven'), fmt.pct(r.breakeven, 2), |
| 192 | r.mid != null ? t('panel.breakevenHint', { mid: fmt.pct(r.mid, 2) }) + (r.edgeNeeded > 0.0005 ? ' · ' + t('panel.edgeNeeded', { pp: fmt.pp(r.edgeNeeded, i18n.lang) }) : '') : null), |
| 193 | ...horizonRows(r, vm)); |
| 194 | parts.push(rows); |
| 195 | if (r.maker && r.maker.saving > 0.005) { |
| 196 | parts.push(note('tip', '💡', t('panel.makerBuy', { price: fmt.cents(r.maker.price), cost: fmt.money(r.maker.cost), saving: fmt.money(r.maker.saving) }))); |
| 197 | } |
| 198 | parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r))); |
| 199 | return parts; |
| 200 | } |
| 201 | |
| 202 | function sellBody(r, vm) { |
| 203 | if (r.noLiquidity || !(r.shares > 0)) return emptyBody(r, vm); |
| 204 | const parts = []; |
| 205 | parts.push(h('div', { class: 'hero' }, |
| 206 | h('div', { class: 'hero-label', text: t('panel.proceeds') }), |
| 207 | h('div', { class: 'hero-row' }, |
| 208 | h('span', { class: 'hero-val', text: fmt.money(r.net) }), |
| 209 | h('span', { class: 'hero-sub', text: fmt.cents(r.netPerShare) + ' ' + t('panel.perShare') })))); |
| 210 | const rows = h('div', { class: 'rows' }); |
| 211 | rows.append( |
| 212 | row(t('panel.avgPrice'), fmt.cents(r.avgPrice), t('panel.bestPrice', { price: fmt.cents(r.bestBid) })), |
| 213 | row(t('panel.slippage'), fmt.pct(r.slippage), fmt.money(r.slippageUsd) + ' · ' + t('panel.levelsN', { n: r.levels })), |
| 214 | row(t('panel.gross'), fmt.money(r.gross)), |
| 215 | feeRow(r, vm.feeModel), |
| 216 | ...horizonRows(Object.assign({}, r, { apr: null }), vm)); |
| 217 | parts.push(rows); |
| 218 | parts.push(note('tip', '⚖', t('panel.holdCompare', { payout: fmt.money(r.holdPayout), p: fmt.pct(r.netPerShare, 1) }))); |
| 219 | if (r.maker && r.maker.gain > 0.005) { |
| 220 | parts.push(note('tip', '💡', t('panel.makerSell', { price: fmt.cents(r.maker.price), gain: fmt.money(r.maker.gain) }))); |
| 221 | } |
| 222 | parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r))); |
| 223 | return parts; |
| 224 | } |
| 225 | |
| 226 | function limitBody(r, vm) { |
| 227 | const parts = []; |
| 228 | if (r.side === 'buy') { |
| 229 | parts.push(h('div', { class: 'hero' }, |
| 230 | h('div', { class: 'hero-label', text: t('panel.ifWins') }), |
| 231 | h('div', { class: 'hero-row' }, |
| 232 | h('span', { class: 'hero-val ' + signClass(r.profit), text: fmt.money(r.profit, { sign: true }) }), |
| 233 | h('span', { class: 'hero-sub', text: fmt.pct(r.roi, null, true) + (aprShown(r) ? ' · ' + fmt.pct(r.apr.simple, null, true) + t('panel.perYear') : '') })))); |
| 234 | } else { |
| 235 | parts.push(h('div', { class: 'hero' }, |
| 236 | h('div', { class: 'hero-label', text: t('panel.limitNet') }), |
| 237 | h('div', { class: 'hero-row' }, h('span', { class: 'hero-val', text: fmt.money(r.net) }), |
| 238 | h('span', { class: 'hero-sub', text: fmt.cents(r.netPerShare) + ' ' + t('panel.perShare') })))); |
| 239 | } |
| 240 | const rows = h('div', { class: 'rows' }); |
| 241 | if (r.side === 'buy') { |
| 242 | rows.append( |
| 243 | row(t('panel.limitCost'), fmt.money(r.totalCost), fmt.number(r.qty, 2) + ' × ' + fmt.cents(r.price)), |
| 244 | row(t('panel.fee'), fmt.money(r.fee)), |
| 245 | row(t('panel.payout'), fmt.money(r.payout)), |
| 246 | row(t('panel.breakeven'), fmt.pct(r.breakeven, 2), r.mid != null ? t('panel.breakevenHint', { mid: fmt.pct(r.mid, 2) }) : null), |
| 247 | ...horizonRows(r, vm)); |
| 248 | } else { |
| 249 | rows.append(row(t('panel.fee'), fmt.money(r.fee)), ...horizonRows(Object.assign({}, r, { apr: null }), vm)); |
| 250 | } |
| 251 | parts.push(rows); |
| 252 | parts.push(note('tip', r.marketable ? '⚡' : '🧾', r.marketable |
| 253 | ? t('panel.limitTaker', { n: fmt.number(r.taker.shares, 2), fee: fmt.money(r.fee), m: fmt.number(r.restingShares, 2) }) |
| 254 | : t('panel.limitPassive'))); |
| 255 | if (r.side === 'sell') parts.push(note('tip', '⚖', t('panel.holdCompare', { payout: fmt.money(r.holdPayout), p: fmt.pct(r.netPerShare, 1) }))); |
| 256 | parts.push(...warnings(r.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, r))); |
| 257 | return parts; |
| 258 | } |
| 259 | |
| 260 | function oneTapBody(res, vm) { |
| 261 | const rows = h('div', { class: 'rows' }); |
| 262 | let last = null; |
| 263 | for (const x of res.rows) { |
| 264 | const r = x.r; |
| 265 | const label = fmt.money(x.usd, { digits: 0 }); |
| 266 | if (r.noLiquidity || !(r.shares > 0)) { rows.append(row(label, fmt.NA)); continue; } |
| 267 | rows.append(row(label, fmt.money(r.payout), t('panel.oneTapSub', { claim: fmt.money(x.claim), fee: fmt.money(r.fee) }))); |
| 268 | last = r; |
| 269 | } |
| 270 | const parts = [h('div', { class: 'hero' }, h('div', { class: 'hero-label', text: t('panel.oneTap') })), rows]; |
| 271 | if (last) parts.push(...warnings(last.warnings, Object.assign({ benchmarkApr: vm.benchmarkApr }, last))); |
| 272 | return parts; |
| 273 | } |
| 274 | |
| 275 | function quoteBody(q, vm) { |
| 276 | if (!q) return [h('div', { class: 'msg', text: vm.hint })]; |
| 277 | const lines = []; |
| 278 | if (q.side === 'buy') { |
| 279 | let s = t('panel.quoteBuy', { price: fmt.cents(q.price), be: fmt.pct(q.breakeven, 2), roi: fmt.pct(q.roi, null, true) }); |
| 280 | if (aprShown(q)) s += ' · ' + t('panel.quoteApr', { apr: fmt.pct(q.apr.simple, null, true) }); |
| 281 | lines.push(h('div', { class: 'msg', text: s })); |
| 282 | if (q.feePerShare > 0) lines.push(h('div', { class: 'msg', text: t('panel.quoteFee', { fee: fmt.money(q.feePer100) }) })); |
| 283 | } else { |
| 284 | lines.push(h('div', { class: 'msg', text: t('panel.quoteSell', { price: fmt.cents(q.price), net: fmt.cents(q.netPerShare) }) })); |
| 285 | } |
| 286 | lines.push(h('div', { class: 'msg', text: vm.hint })); |
| 287 | return lines; |
| 288 | } |
| 289 | |
| 290 | function manualBlock(vm) { |
| 291 | const m = vm.manual; |
| 292 | if (!m) return null; |
| 293 | const wrap = h('div', { class: 'manual' }); |
| 294 | if (m.markets && m.markets.length > 1) { |
| 295 | const sel = h('select', { 'data-fk': 'market', 'aria-label': t('panel.market'), onchange: (e) => act('manualMarket', e.target.value) }, |
| 296 | h('option', { value: '', text: t('panel.pickMarket') }), |
| 297 | m.markets.map((mk) => h('option', { value: mk.id, selected: mk.id === m.marketId, text: mk.label }))); |
| 298 | wrap.append(h('div', null, h('label', { text: t('panel.market') }), sel)); |
| 299 | } |
| 300 | if (m.full) { |
| 301 | wrap.append(h('div', { class: 'seg' }, |
| 302 | ['buy', 'sell'].map((s) => h('button', { 'data-fk': 'side-' + s, class: m.side === s ? 'on' : '', 'aria-pressed': String(m.side === s), onclick: () => act('manualSide', s), text: t('panel.' + s) })))); |
| 303 | if (m.outcomes && m.outcomes.length) { |
| 304 | wrap.append(h('div', { class: 'seg' }, |
| 305 | m.outcomes.map((o, i) => h('button', { 'data-fk': 'out-' + i, class: m.outcomeIdx === i ? 'on' : '', 'aria-pressed': String(m.outcomeIdx === i), title: o, onclick: () => act('manualOutcome', i), text: o })))); |
| 306 | } |
| 307 | const input = h('input', { |
| 308 | 'data-fk': 'amount', type: 'text', inputmode: 'decimal', placeholder: m.side === 'sell' ? t('panel.shares') : t('panel.amountUsd'), |
| 309 | value: m.amountRaw || '', 'aria-label': m.side === 'sell' ? t('panel.shares') : t('panel.amountUsd'), |
| 310 | oninput: (e) => act('manualAmount', e.target.value), |
| 311 | }); |
| 312 | wrap.append(input); |
| 313 | } |
| 314 | return wrap; |
| 315 | } |
| 316 | |
| 317 | // The panel is rebuilt section by section: the live book refreshes every few seconds and must not recreate the |
| 318 | // manual inputs (open <select>, caret, half-typed "1.") or the header buttons. |
| 319 | const parts = { head: h('div'), body: h('div', { class: 'body' }) }; |
| 320 | const sec = { ctx: h('div'), manual: h('div'), content: h('div'), foot: h('div') }; |
| 321 | parts.body.append(sec.ctx, sec.manual, sec.content, sec.foot); |
| 322 | box.append(parts.head, parts.body); |
| 323 | const keys = {}; |
| 324 | let ageEl = null; |
| 325 | const changed = (name, value) => { |
| 326 | const k = JSON.stringify(value, (kk, v) => (typeof v === 'number' ? Math.round(v * 1e6) / 1e6 : v)); |
| 327 | if (keys[name] === k) return false; |
| 328 | keys[name] = k; |
| 329 | return true; |
| 330 | }; |
| 331 | let liveNow = false; |
| 332 | function setAge(sec) { |
| 333 | if (!ageEl) return; |
| 334 | ageEl.textContent = liveNow ? '● ' + t('panel.live') : sec != null ? t('panel.updated', { s: sec }) : ''; |
| 335 | ageEl.classList.toggle('live', liveNow); |
| 336 | } |
| 337 | |
| 338 | function summaryText(vm) { |
| 339 | const r = vm.result; |
| 340 | if (r && r.kind === 'onetap') return ''; |
| 341 | if (!r || r.noLiquidity || !(r.shares > 0 || (r.kind === 'limit' && r.qty > 0))) return ''; |
| 342 | if (r.kind === 'sell' || (r.kind === 'limit' && r.side === 'sell')) return t('panel.summarySell', { net: fmt.money(r.net), fee: fmt.money(r.fee) }); |
| 343 | return t('panel.summaryBuy', { profit: fmt.money(r.profit, { sign: true }), be: fmt.pct(r.breakeven, 2), fee: fmt.money(r.fee) }); |
| 344 | } |
| 345 | |
| 346 | function render(vm) { |
| 347 | const lang = i18n.lang; |
| 348 | const focusKey = shadow.activeElement && shadow.activeElement.getAttribute('data-fk'); |
| 349 | // floating + minimized: only a small round button, nothing else |
| 350 | const pill = floating && !!vm.floatMin; |
| 351 | box.classList.toggle('min', pill); |
| 352 | if (pill) { |
| 353 | if (changed('head', ['pill', lang])) { |
| 354 | parts.head.replaceChildren(h('button', { class: 'pill', 'data-fk': 'pill', title: t('panel.openPanel'), 'aria-label': t('panel.openPanel'), |
| 355 | onclick: () => act('floatOpen') }, h('img', { src: LOGO, alt: '' }))); |
| 356 | } |
| 357 | parts.body.hidden = true; |
| 358 | return; |
| 359 | } |
| 360 | parts.body.hidden = false; |
| 361 | setCollapsed(vm.collapsed && !floating); |
| 362 | const summary = vm.collapsed && !floating ? summaryText(vm) : ''; |
| 363 | if (changed('head', [lang, vm.collapsed, floating, summary])) { |
| 364 | const toggleBtn = floating |
| 365 | ? h('button', { class: 'icon-btn', 'data-fk': 'toggle', title: t('panel.minimize'), 'aria-label': t('panel.minimize'), onclick: () => act('floatClose'), text: '×' }) |
| 366 | : h('button', { |
| 367 | class: 'icon-btn', 'data-fk': 'toggle', title: vm.collapsed ? t('panel.expand') : t('panel.collapse'), |
| 368 | 'aria-label': vm.collapsed ? t('panel.expand') : t('panel.collapse'), 'aria-expanded': String(!vm.collapsed), |
| 369 | onclick: () => act('toggleCollapse'), text: vm.collapsed ? '▸' : '▾', |
| 370 | }); |
| 371 | parts.head.replaceChildren(h('header', null, |
| 372 | h('img', { class: 'logo', src: LOGO, alt: '' }), |
| 373 | h('span', { class: 'brand', text: t('brand') }), |
| 374 | h('span', { class: 'title', text: '· ' + t('panel.title') }), |
| 375 | toggleBtn), summary ? h('div', { class: 'summary', role: 'status', text: summary }) : ''); |
| 376 | } |
| 377 | if (changed('ctx', [lang, vm.context || null, vm.synced])) { |
| 378 | sec.ctx.replaceChildren(vm.context ? h('div', { class: 'ctx' }, |
| 379 | h('b', { text: vm.context.side }), vm.context.outcome ? '\u00a0· ' : null, vm.context.outcome ? h('b', { text: vm.context.outcome }) : null, |
| 380 | vm.context.market ? '\u00a0· ' + vm.context.market : null, |
| 381 | h('span', { class: 'sync' }, h('span', { class: 'dot' + (vm.synced ? '' : ' off') }), vm.synced ? t('panel.synced') : t('panel.manual'))) : ''); |
| 382 | } |
| 383 | // the typed text lives in the input itself; it is not part of the key |
| 384 | const manualKey = vm.manual ? Object.assign({}, vm.manual, { amountRaw: undefined }) : null; |
| 385 | if (changed('manual', [lang, manualKey])) sec.manual.replaceChildren(manualBlock(vm) || ''); |
| 386 | const contentKey = [lang, vm.status, vm.error, vm.result || null, vm.quote || null, vm.hint, vm.closed, vm.feeModel, |
| 387 | vm.holdingRate, vm.endDateText, vm.benchmarkApr]; |
| 388 | if (changed('content', contentKey)) { |
| 389 | const out = []; |
| 390 | if (vm.status === 'loading') out.push(h('div', { class: 'msg', text: t('panel.loading') })); |
| 391 | else if (vm.status === 'error') out.push(h('div', { class: 'msg', text: t('panel.loadFail', { err: vm.error || '?' }) })); |
| 392 | else if (vm.status === 'nobook') out.push(h('div', { class: 'msg', text: t('panel.noBook') })); |
| 393 | else if (vm.status === 'pick') { |
| 394 | if (!(vm.manual && vm.manual.markets && vm.manual.markets.length > 1)) out.push(h('div', { class: 'msg', text: t('panel.pickMarket') })); |
| 395 | } |
| 396 | else if (vm.result) { |
| 397 | const r = vm.result; |
| 398 | out.push(...(r.kind === 'buy' ? buyBody(r, vm) : r.kind === 'sell' ? sellBody(r, vm) : r.kind === 'onetap' ? oneTapBody(r, vm) : limitBody(r, vm))); |
| 399 | } else out.push(...quoteBody(vm.quote, vm)); |
| 400 | if (vm.closed) out.push(note('warn', '⚠', t('warn.closed'))); |
| 401 | sec.content.replaceChildren(...out); |
| 402 | } |
| 403 | if (changed('foot', [lang])) { |
| 404 | ageEl = h('span'); |
| 405 | sec.foot.replaceChildren( |
| 406 | h('div', { class: 'foot', role: 'group' }, |
| 407 | ageEl, |
| 408 | journal ? h('button', { type: 'button', class: 'linkbtn', onclick: () => act('openJournal'), text: t('panel.openJournal') }) : null, |
| 409 | h('button', { type: 'button', class: 'linkbtn', onclick: () => act('openSettings'), text: t('panel.settings') })), |
| 410 | h('div', { class: 'disc', text: t('common.notAffiliated') })); |
| 411 | } |
| 412 | liveNow = !!vm.live; |
| 413 | setAge(vm.bookAge); |
| 414 | if (focusKey && !(shadow.activeElement && shadow.activeElement.getAttribute('data-fk') === focusKey)) { |
| 415 | const el = shadow.querySelector('[data-fk="' + focusKey + '"]'); // keep keyboard position across rebuilds |
| 416 | if (el) el.focus(); |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | return { host, mountAfter, mountFloat, hide, render, setTheme, get floating() { return floating; } }; |
| 421 | } |
| 422 | |
| 423 | root.PWL = root.PWL || {}; |
| 424 | root.PWL.panel = { create, h }; |
| 425 | })(typeof globalThis !== 'undefined' ? globalThis : this); |