const { div, h2, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br } = require("../server/node_modules/hyperaxe"); const { template, i18n, userLink } = require("../views/main_views"); const moment = require("../server/node_modules/moment"); const FILTER_LABELS = { overview: i18n.bankOverview, exchange: i18n.bankExchange, mine: i18n.mine, pending: i18n.pending, closed: i18n.closed, claimed: i18n.bankStatusClaimed, expired: i18n.bankStatusExpired, epochs: i18n.bankEpochs, rules: i18n.bankRules, addresses: i18n.bankAddresses }; const generateFilterButtons = (filters, currentFilter, action) => div({ class: "mode-buttons-row" }, ...filters.map(mode => form({ method: "GET", action }, input({ type: "hidden", name: "filter", value: mode }), button({ type: "submit", class: currentFilter === mode ? "filter-btn active" : "filter-btn" }, (FILTER_LABELS[mode] || mode).toUpperCase()) ) ) ); const kvRow = (label, value) => tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value)); const fmtIndex = (value) => { return value ? value.toFixed(6) : "0.000000"; }; const pct = (value) => { if (value === undefined || value === null) return "0.000001%"; const formattedValue = (value).toFixed(6); const sign = value >= 0 ? "+" : ""; return `${sign}${formattedValue}%`; }; const fmtDate = (timestamp) => { return moment(timestamp).format('YYYY-MM-DD HH:mm:ss'); }; const fmtEcoTime = (ms) => { if (!ms || ms <= 0) return `0 ${i18n.bankUnitMs || 'ms'}`; if (ms < 1000) return `${Number(ms).toFixed(3)} ${i18n.bankUnitMs || 'ms'}`; const s = ms / 1000; if (s < 60) return `${s.toFixed(2)} ${i18n.bankUnitSeconds || 'seconds'}`; const m = s / 60; if (m < 60) return `${m.toFixed(2)} ${i18n.bankUnitMinutes || 'minutes'}`; const h = m / 60; if (h < 24) return `${h.toFixed(2)} ${i18n.bankHoursOfWork || 'hours'}`; return `${(h / 24).toFixed(2)} ${i18n.bankUnitDays || 'days'}`; }; const escAttr = (s) => String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const buildEcoValueChartSvg = (history, labels) => { const arr = Array.isArray(history) ? history.slice(-120) : []; const W = 720, H = 320; const padL = 56, padR = 16, padT = 16, padB = 70; const plotW = W - padL - padR; const plotH = H - padT - padB; if (arr.length < 2) { return `` + `` + `${escAttr(labels.empty || 'Not enough samples yet')}` + ``; } const values = arr.map(s => Number(s.ecoValue || 0)); const supplies = arr.map(s => Number(s.currentSupply || 0)); const inflations = arr.map(s => Number(s.inflationFactor || 0)); const minV = Math.min(...values); const maxV = Math.max(...values); const rangeV = maxV - minV || 1; const minS = Math.min(...supplies); const maxS = Math.max(...supplies); const rangeS = maxS - minS || 1; const minI = Math.min(...inflations); const maxI = Math.max(...inflations); const rangeI = maxI - minI || 1; const stepX = arr.length > 1 ? plotW / (arr.length - 1) : plotW; const xy = (i, v, minR, rangeR) => { const x = padL + i * stepX; const y = padT + plotH - ((v - minR) / rangeR) * plotH; return `${x.toFixed(2)},${y.toFixed(2)}`; }; const pointsValue = values.map((v, i) => xy(i, v, minV, rangeV)).join(' '); const pointsSupply = supplies.map((v, i) => xy(i, v, minS, rangeS)).join(' '); const pointsInfl = inflations.map((v, i) => xy(i, v, minI, rangeI)).join(' '); const tsStart = moment(arr[0].ts).format('YYYY-MM-DD HH:mm'); const tsEnd = moment(arr[arr.length - 1].ts).format('YYYY-MM-DD HH:mm'); const tsMid = moment(arr[Math.floor(arr.length / 2)].ts).format('YYYY-MM-DD HH:mm'); const yTicks = 4; const grid = []; for (let i = 0; i <= yTicks; i++) { const y = padT + (plotH / yTicks) * i; grid.push(``); const val = maxV - (rangeV / yTicks) * i; grid.push(`${val.toFixed(4)}`); } const xLabelY = padT + plotH + 16; const xLabels = `${escAttr(tsStart)}` + `${escAttr(tsMid)}` + `${escAttr(tsEnd)}`; const legendY = padT + plotH + 44; const legendBaseX = padL; const legend = `` + `` + `${escAttr(labels.value || 'Value')}` + `` + `${escAttr(labels.supply || 'Supply')}` + `` + `${escAttr(labels.inflation || 'Inflation')}` + ``; return `` + `` + grid.join('') + `` + `` + `` + xLabels + legend + ``; }; const renderExchange = (ex, history) => { if (!ex) return div(p(i18n.bankExchangeNoData)); const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated; const syncStatusClass = ex.isSynced ? 'synced' : 'outdated'; const ecoTimeLabel = ex.isSynced ? fmtEcoTime(ex.ecoTimeMs) : fmtEcoTime(0); const chartLabels = { value: i18n.bankExchangeChartValue || 'Value (ECO/h)', supply: i18n.bankExchangeChartSupply || 'Supply', inflation: i18n.bankExchangeChartInflation || 'Inflation %', empty: i18n.bankExchangeChartEmpty || 'Not enough samples yet — revisit later' }; const hasEnoughSamples = Array.isArray(history) && history.length >= 2 && ex.isSynced; return div( div({ class: "bank-summary" }, table({ class: "bank-info-table" }, tbody( kvRow(i18n.bankingSyncStatus, span({ class: syncStatusClass }, syncStatus) ), kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`), kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`), kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`), kvRow(i18n.bankEcoinHours, ecoTimeLabel), kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`), kvRow(i18n.bankInflationMonthly, `${Number(ex.inflationMonthly || 0).toFixed(2)}%`) ) ) ), hasEnoughSamples ? div({ class: "bank-eco-chart-block" }, h2({ class: "bank-eco-chart-title" }, i18n.bankExchangeChartTitle || 'ECOin value over time'), div({ class: "bank-eco-chart-canvas", innerHTML: buildEcoValueChartSvg(history, chartLabels) }) ) : null ); }; const renderOverviewSummaryTable = (s, rules) => { const score = Number(s.userEngagementScore || 0); const pool = Number(s.pool || 0); const W = Math.max(1, Number(s.weightsSum || 1)); const w = 1 + score / 100; const cap = rules?.caps?.cap_user_epoch ?? 50; const future = Math.min(pool * (w / W), cap); const availClass = s.ubiAvailability === "OK" ? "ubi-available" : "ubi-unavailable"; const availLabel = s.ubiAvailability === "OK" ? i18n.bankUbiAvailableOk : i18n.bankUbiAvailableNo; return div({ class: "bank-summary" }, table({ class: "bank-info-table" }, tbody( kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`), kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)), s.pubId ? kvRow(i18n.pubIdLabel, userLink(s.pubId)) : null, kvRow(i18n.bankEpoch, String(s.epochId || "-")), kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`), kvRow(i18n.bankWeightsSum, String(W.toFixed(6))), kvRow(i18n.bankingUserEngagementScore, String(score)), kvRow(i18n.bankUbiThisMonth, `${future.toFixed(6)} ECO`) ) ) ); }; const renderClaimUBIBlock = (pendingAllocation, isPub, alreadyClaimed, pubId, hasValidWallet, ubiAvailability) => { if (alreadyClaimed) return ""; if (!pubId && !isPub) return ""; if (!isPub && !hasValidWallet) return ""; if (!isPub && ubiAvailability !== "OK") return ""; if (!pendingAllocation && !isPub) { return div({ class: "bank-claim-ubi" }, div({ class: "bank-claim-card" }, form({ method: "POST", action: "/banking/claim-ubi" }, button({ type: "submit", class: "create-button bank-claim-btn" }, i18n.bankClaimUBI) ) ) ); } if (!pendingAllocation) return ""; return div({ class: "bank-claim-ubi" }, div({ class: "bank-claim-card" }, p(`${i18n.bankUbiThisMonth}: `, span({ class: "accent" }, `${Number(pendingAllocation.amount || 0).toFixed(6)} ECO`)), p(`${i18n.bankEpoch}: `, span(pendingAllocation.concept || "")), form({ method: "POST", action: `/banking/claim/${encodeURIComponent(pendingAllocation.id)}` }, button({ type: "submit", class: "create-button bank-claim-btn" }, isPub ? i18n.bankClaimAndPay : i18n.bankClaimUBI) ) ) ); }; const filterAllocations = (allocs, filter, userId) => { if (filter === "mine") return allocs.filter(a => a.to === userId && (a.status === "UNCLAIMED" || a.status === "UNCONFIRMED")); if (filter === "pending") return allocs.filter(a => a.status === "UNCLAIMED" || a.status === "UNCONFIRMED"); if (filter === "closed") return allocs.filter(a => a.status === "CLOSED"); if (filter === "claimed") return allocs.filter(a => a.status === "CLAIMED"); if (filter === "expired") return allocs.filter(a => a.status === "EXPIRED"); return allocs; }; const allocationsTable = (rows = [], userId) => rows.length === 0 ? div(p(i18n.bankNoAllocations)) : table( { class: "bank-allocs" }, thead( tr( th(i18n.bankAllocDate), th(i18n.bankAllocConcept), th(i18n.bankAllocFrom), th(i18n.bankAllocTo), th(i18n.bankAllocAmount), th(i18n.bankAllocStatus), th("") ) ), tbody( ...rows.map(r => tr( td(new Date(r.createdAt).toLocaleString()), td(r.concept || ""), td(userLink(r.from)), td(userLink(r.to)), td(String(Number(r.amount || 0).toFixed(6))), td(r.status), td( (r.status === "UNCLAIMED" || r.status === "UNCONFIRMED") && r.to === userId ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow) ) : r.status === "CLOSED" && r.txid ? a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(r.txid)}`, target: "_blank", class: "btn-singleview" }, i18n.bankViewTx) : null ) ) ) ) ); const renderEpochList = (epochs = []) => epochs.length === 0 ? div(p(i18n.bankNoEpochs)) : table( { class: "bank-epochs" }, thead(tr(th(i18n.bankEpochId), th(i18n.bankPool), th(i18n.bankWeightsSum), th(i18n.bankRuleHash), th(""))), tbody( ...epochs .sort((a, b) => String(b.id).localeCompare(String(a.id))) .map(e => tr( td(e.id), td(String(Number(e.pool || 0).toFixed(6))), td(String(Number(e.weightsSum || 0).toFixed(6))), td(e.hash || "-"), td( form({ method: "GET", action: `/banking/epoch/${encodeURIComponent(e.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.bankViewEpoch) ) ) ) ) ) ); const rulesBlock = (rules) => div({ class: "bank-rules" }, pre({ class: "json-content" }, JSON.stringify(rules || {}, null, 2))); const flashText = (key) => { if (key === "added") return i18n.bankAddressAdded; if (key === "updated") return i18n.bankAddressUpdated; if (key === "exists") return i18n.bankAddressExists; if (key === "invalid") return i18n.bankAddressInvalid; if (key === "deleted") return i18n.bankAddressDeleted; if (key === "not_found") return i18n.bankAddressNotFound; if (key === "claimed_pending") return i18n.bankClaimedPending; if (key === "already_claimed") return i18n.bankAlreadyClaimedThisMonth; if (key === "no_pub_configured") return i18n.bankNoPubConfigured; if (key === "no_funds") return i18n.bankUbiAvailableNo; if (key === "forbidden") return i18n.bankAddressForbidden; return ""; }; const flashBanner = (msgKey) => !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey) || msgKey)); const addressesToolbar = (rows = [], search = "") => div({ class: "addr-toolbar" }, div({ class: "addr-counter accent-pill" }, span({ class: "acc-title accent" }, i18n.bankAddressTotal + ":"), span({ class: "acc-badge" }, String(rows.length)) ), form({ method: "GET", action: "/banking", class: "addr-search" }, input({ type: "hidden", name: "filter", value: "addresses" }), input({ type: "text", name: "q", placeholder: i18n.bankAddressSearch, value: search || "" }), br(), button({ type: "submit", class: "filter-btn" }, i18n.search) ) ); const renderAddresses = (data, userId) => { const rows = data.addresses || []; const search = data.search || ""; return div( data.flash ? flashBanner(data.flash) : null, addressesToolbar(rows, search), div({ class: "bank-addresses-stack" }, div({ class: "addr-form-card wide" }, h2(i18n.bankAddAddressTitle), form({ method: "POST", action: "/banking/addresses", class: "addr-form" }, div({ class: "form-row" }, span({ class: "form-label accent" }, i18n.bankAddAddressUser + ":"), input({ class: "form-input xl", type: "text", name: "userId", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", placeholder: "@...=.ed25519", id: "addr-user-id" }) ), div({ class: "form-row" }, span({ class: "form-label accent" }, i18n.bankAddAddressAddress + ":"), input({ class: "form-input xl", type: "text", name: "address", required: true, pattern: "^[A-Za-z0-9]{20,64}$", placeholder: "ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX" }) ), div({ class: "form-actions" }, button({ type: "submit", class: "filter-btn" }, i18n.bankAddAddressSave) ) ) ), div({ class: "addr-list-card" }, rows.length === 0 ? div(p(i18n.bankNoAddresses)) : table( { class: "bank-addresses" }, thead( tr( th(i18n.bankUser), th(i18n.bankAddress), th(i18n.bankAddressSource), th(i18n.bankAddressActions) ) ), tbody( ...rows.map(r => tr( td(userLink(r.id)), td(r.address), td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis), td( div({ class: "row-actions" }, form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" }, input({ type: "hidden", name: "userId", value: r.id }), input({ type: "hidden", name: "source", value: r.source || "local" }), button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete) ) ) ) ) ) ) ) ) ) ); }; const renderBankingView = (data, filter, userId, isPub) => template( i18n.banking, section( div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)), data.flash ? div({ class: "flash-banner" }, p(flashText(data.flash) || data.flash)) : null, generateFilterButtons(["overview","exchange","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"), filter === "overview" ? div( renderOverviewSummaryTable(data.summary || {}, data.rules), renderClaimUBIBlock(data.pendingUBI || null, isPub, data.alreadyClaimed, (data.summary || {}).pubId, (data.summary || {}).hasValidWallet, (data.summary || {}).ubiAvailability), allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId) ) : filter === "exchange" ? renderExchange(data.exchange, data.exchangeHistory) : filter === "epochs" ? renderEpochList(data.epochs || []) : filter === "rules" ? rulesBlock(data.rules || {}) : filter === "addresses" ? renderAddresses(data, userId) : allocationsTable( filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId), userId ) ) ); const renderSingleAllocationView = (alloc, userId) => { if (!alloc) return template(i18n.banking, section(div(p(i18n.bankNoAllocations)))); return template( i18n.banking, section( div({ class: "tags-header" }, h2(i18n.banking)), div({ class: "bank-summary" }, table({ class: "bank-info-table" }, tbody( kvRow("ID", alloc.id || "-"), kvRow(i18n.bankAllocConcept, alloc.concept || "-"), kvRow(i18n.bankAllocFrom, alloc.from || "-"), kvRow(i18n.bankAllocTo, alloc.to || "-"), kvRow(i18n.bankAllocAmount, `${Number(alloc.amount || 0).toFixed(6)} ECO`), kvRow(i18n.bankAllocStatus, alloc.status || "-"), kvRow(i18n.bankAllocDate, alloc.createdAt ? fmtDate(alloc.createdAt) : "-"), alloc.txid ? kvRow("TxID", a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(alloc.txid)}`, target: "_blank" }, alloc.txid)) : null ) ) ), alloc.status === "UNCONFIRMED" && alloc.to === userId ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(alloc.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow) ) : null, div(a({ href: "/banking", class: "filter-btn" }, i18n.bankOverview)) ) ); }; const renderEpochView = (epoch, allocations) => { if (!epoch) return template(i18n.banking, section(div(p(i18n.bankNoEpochs)))); return template( i18n.banking, section( div({ class: "tags-header" }, h2(`${i18n.bankEpoch}: ${epoch.id}`)), div({ class: "bank-summary" }, table({ class: "bank-info-table" }, tbody( kvRow(i18n.bankEpochId, epoch.id || "-"), kvRow(i18n.bankPool, `${Number(epoch.pool || 0).toFixed(6)} ECO`), kvRow(i18n.bankWeightsSum, String(Number(epoch.weightsSum || 0).toFixed(6))), kvRow(i18n.bankRuleHash, epoch.hash || "-") ) ) ), h2(i18n.bankEpochAllocations), allocationsTable(allocations || [], "") ) ); }; module.exports = { renderBankingView, renderSingleAllocationView, renderEpochView };