banking_views.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. const { div, h2, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br } = require("../server/node_modules/hyperaxe");
  2. const { template, i18n } = require("../views/main_views");
  3. const moment = require("../server/node_modules/moment");
  4. const FILTER_LABELS = {
  5. overview: i18n.bankOverview,
  6. exchange: i18n.bankExchange,
  7. mine: i18n.mine,
  8. pending: i18n.pending,
  9. closed: i18n.closed,
  10. epochs: i18n.bankEpochs,
  11. rules: i18n.bankRules,
  12. addresses: i18n.bankAddresses
  13. };
  14. const generateFilterButtons = (filters, currentFilter, action) =>
  15. div({ class: "mode-buttons-row" },
  16. ...filters.map(mode =>
  17. form({ method: "GET", action },
  18. input({ type: "hidden", name: "filter", value: mode }),
  19. button({ type: "submit", class: currentFilter === mode ? "filter-btn active" : "filter-btn" }, (FILTER_LABELS[mode] || mode).toUpperCase())
  20. )
  21. )
  22. );
  23. const kvRow = (label, value) =>
  24. tr(td({ class: "card-label" }, label), td({ class: "card-value" }, value));
  25. const fmtIndex = (value) => {
  26. return value ? value.toFixed(6) : "0.000000";
  27. };
  28. const pct = (value) => {
  29. if (value === undefined || value === null) return "0.000001%";
  30. const formattedValue = (value).toFixed(6);
  31. const sign = value >= 0 ? "+" : "";
  32. return `${sign}${formattedValue}%`;
  33. };
  34. const fmtDate = (timestamp) => {
  35. return moment(timestamp).format('YYYY-MM-DD HH:mm:ss');
  36. };
  37. const renderExchange = (ex) => {
  38. if (!ex) return div(p(i18n.bankExchangeNoData));
  39. const syncStatus = ex.isSynced ? i18n.bankingSyncStatusSynced : i18n.bankingSyncStatusOutdated;
  40. const syncStatusClass = ex.isSynced ? 'synced' : 'outdated';
  41. const ecoInHours = ex.isSynced ? ex.ecoInHours : 0;
  42. return div(
  43. div({ class: "bank-summary" },
  44. table({ class: "bank-info-table" },
  45. tbody(
  46. kvRow(i18n.bankingSyncStatus,
  47. span({ class: syncStatusClass }, syncStatus)
  48. ),
  49. kvRow(i18n.bankExchangeCurrentValue, `${fmtIndex(ex.ecoValue)} ECO`),
  50. kvRow(i18n.bankCurrentSupply, `${Number(ex.currentSupply || 0).toFixed(6)} ECO`),
  51. kvRow(i18n.bankTotalSupply, `${Number(ex.totalSupply || 0).toFixed(6)} ECO`),
  52. kvRow(i18n.bankEcoinHours, `${ecoInHours} ${i18n.bankHoursOfWork}`),
  53. kvRow(i18n.bankInflation, `${ex.inflationFactor.toFixed(2)}%`)
  54. )
  55. )
  56. )
  57. );
  58. };
  59. const renderOverviewSummaryTable = (s, rules) => {
  60. const score = Number(s.userEngagementScore || 0);
  61. const pool = Number(s.pool || 0);
  62. const W = Math.max(1, Number(s.weightsSum || 1));
  63. const w = 1 + score / 100;
  64. const cap = rules?.caps?.cap_user_epoch ?? 50;
  65. const future = Math.min(pool * (w / W), cap);
  66. return div({ class: "bank-summary" },
  67. table({ class: "bank-info-table" },
  68. tbody(
  69. kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
  70. kvRow(i18n.bankPubBalance, `${Number(s.pubBalance || 0).toFixed(6)} ECO`),
  71. kvRow(i18n.bankEpoch, String(s.epochId || "-")),
  72. kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
  73. kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
  74. kvRow(i18n.bankingUserEngagementScore, String(score)),
  75. kvRow(i18n.bankingFutureUBI, `${future.toFixed(6)} ECO`)
  76. )
  77. )
  78. );
  79. };
  80. function calculateFutureUBI(userEngagementScore, poolAmount) {
  81. const maxScore = 100;
  82. const scorePercentage = userEngagementScore / maxScore;
  83. const estimatedUBI = poolAmount * scorePercentage;
  84. return estimatedUBI;
  85. }
  86. const filterAllocations = (allocs, filter, userId) => {
  87. if (filter === "mine") return allocs.filter(a => a.to === userId && a.status === "UNCONFIRMED");
  88. if (filter === "pending") return allocs.filter(a => a.status === "UNCONFIRMED");
  89. if (filter === "closed") return allocs.filter(a => a.status === "CLOSED");
  90. return allocs;
  91. };
  92. const allocationsTable = (rows = [], userId) =>
  93. rows.length === 0
  94. ? div(p(i18n.bankNoAllocations))
  95. : table(
  96. { class: "bank-allocs" },
  97. thead(
  98. tr(
  99. th(i18n.bankAllocDate),
  100. th(i18n.bankAllocConcept),
  101. th(i18n.bankAllocFrom),
  102. th(i18n.bankAllocTo),
  103. th(i18n.bankAllocAmount),
  104. th(i18n.bankAllocStatus),
  105. th("")
  106. )
  107. ),
  108. tbody(
  109. ...rows.map(r =>
  110. tr(
  111. td(new Date(r.createdAt).toLocaleString()),
  112. td(r.concept || ""),
  113. td(a({ href: `/author/${encodeURIComponent(r.from)}`, class: "user-link" }, r.from)),
  114. td(a({ href: `/author/${encodeURIComponent(r.to)}`, class: "user-link" }, r.to)),
  115. td(String(Number(r.amount || 0).toFixed(6))),
  116. td(r.status),
  117. td(
  118. r.status === "UNCONFIRMED" && r.to === userId
  119. ? form({ method: "POST", action: `/banking/claim/${encodeURIComponent(r.id)}` },
  120. button({ type: "submit", class: "filter-btn" }, i18n.bankClaimNow)
  121. )
  122. : r.status === "CLOSED" && r.txid
  123. ? a({ href: `https://ecoin.03c8.net/blockexplorer/search?q=${encodeURIComponent(r.txid)}`, target: "_blank", class: "btn-singleview" }, i18n.bankViewTx)
  124. : null
  125. )
  126. )
  127. )
  128. )
  129. );
  130. const renderEpochList = (epochs = []) =>
  131. epochs.length === 0
  132. ? div(p(i18n.bankNoEpochs))
  133. : table(
  134. { class: "bank-epochs" },
  135. thead(tr(th(i18n.bankEpochId), th(i18n.bankPool), th(i18n.bankWeightsSum), th(i18n.bankRuleHash), th(""))),
  136. tbody(
  137. ...epochs
  138. .sort((a, b) => String(b.id).localeCompare(String(a.id)))
  139. .map(e =>
  140. tr(
  141. td(e.id),
  142. td(String(Number(e.pool || 0).toFixed(6))),
  143. td(String(Number(e.weightsSum || 0).toFixed(6))),
  144. td(e.hash || "-"),
  145. td(
  146. form({ method: "GET", action: `/banking/epoch/${encodeURIComponent(e.id)}` },
  147. button({ type: "submit", class: "filter-btn" }, i18n.bankViewEpoch)
  148. )
  149. )
  150. )
  151. )
  152. )
  153. );
  154. const rulesBlock = (rules) =>
  155. div({ class: "bank-rules" }, pre({ class: "json-content" }, JSON.stringify(rules || {}, null, 2)));
  156. const flashText = (key) => {
  157. if (key === "added") return i18n.bankAddressAdded;
  158. if (key === "updated") return i18n.bankAddressUpdated;
  159. if (key === "exists") return i18n.bankAddressExists;
  160. if (key === "invalid") return i18n.bankAddressInvalid;
  161. if (key === "deleted") return i18n.bankAddressDeleted;
  162. if (key === "not_found") return i18n.bankAddressNotFound;
  163. return "";
  164. };
  165. const flashBanner = (msgKey) =>
  166. !msgKey ? null : div({ class: "flash-banner" }, p(flashText(msgKey)));
  167. const addressesToolbar = (rows = [], search = "") =>
  168. div({ class: "addr-toolbar" },
  169. div({ class: "addr-counter accent-pill" },
  170. span({ class: "acc-title accent" }, i18n.bankAddressTotal + ":"),
  171. span({ class: "acc-badge" }, String(rows.length))
  172. ),
  173. form({ method: "GET", action: "/banking", class: "addr-search" },
  174. input({ type: "hidden", name: "filter", value: "addresses" }),
  175. input({ type: "text", name: "q", placeholder: i18n.bankAddressSearch, value: search || "" }),
  176. br(),
  177. button({ type: "submit", class: "filter-btn" }, i18n.search)
  178. )
  179. );
  180. const renderAddresses = (data, userId) => {
  181. const rows = data.addresses || [];
  182. const search = data.search || "";
  183. return div(
  184. data.flash ? flashBanner(data.flash) : null,
  185. addressesToolbar(rows, search),
  186. div({ class: "bank-addresses-stack" },
  187. div({ class: "addr-form-card wide" },
  188. h2(i18n.bankAddAddressTitle),
  189. form({ method: "POST", action: "/banking/addresses", class: "addr-form" },
  190. div({ class: "form-row" },
  191. span({ class: "form-label accent" }, i18n.bankAddAddressUser + ":"),
  192. input({
  193. class: "form-input xl",
  194. type: "text",
  195. name: "userId",
  196. required: true,
  197. pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$",
  198. placeholder: "@...=.ed25519",
  199. id: "addr-user-id"
  200. })
  201. ),
  202. div({ class: "form-row" },
  203. span({ class: "form-label accent" }, i18n.bankAddAddressAddress + ":"),
  204. input({
  205. class: "form-input xl",
  206. type: "text",
  207. name: "address",
  208. required: true,
  209. pattern: "^[A-Za-z0-9]{20,64}$",
  210. placeholder: "ETQ17sBv8QFoiCPGKDQzNcDJeXmB2317HX"
  211. })
  212. ),
  213. div({ class: "form-actions" },
  214. button({ type: "submit", class: "filter-btn" }, i18n.bankAddAddressSave)
  215. )
  216. )
  217. ),
  218. div({ class: "addr-list-card" },
  219. rows.length === 0
  220. ? div(p(i18n.bankNoAddresses))
  221. : table(
  222. { class: "bank-addresses" },
  223. thead(
  224. tr(
  225. th(i18n.bankUser),
  226. th(i18n.bankAddress),
  227. th(i18n.bankAddressSource),
  228. th(i18n.bankAddressActions)
  229. )
  230. ),
  231. tbody(
  232. ...rows.map(r =>
  233. tr(
  234. td(a({ href: `/author/${encodeURIComponent(r.id)}`, class: "user-link" }, r.id)),
  235. td(r.address),
  236. td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
  237. td(
  238. div({ class: "row-actions" },
  239. r.source === "local"
  240. ? form({ method: "POST", action: "/banking/addresses/delete", class: "addr-del" },
  241. input({ type: "hidden", name: "userId", value: r.id }),
  242. button({ type: "submit", class: "delete-btn", onclick: `return confirm(${JSON.stringify(i18n.bankAddressDeleteConfirm)})` }, i18n.bankAddressDelete)
  243. )
  244. : null
  245. )
  246. )
  247. )
  248. )
  249. )
  250. )
  251. )
  252. )
  253. );
  254. };
  255. const renderBankingView = (data, filter, userId) =>
  256. template(
  257. i18n.banking,
  258. section(
  259. div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
  260. generateFilterButtons(["overview", "exchange", "mine", "pending", "closed", "epochs", "rules", "addresses"], filter, "/banking"),
  261. filter === "overview"
  262. ? div(
  263. renderOverviewSummaryTable(data.summary || {}, data.rules),
  264. allocationsTable((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), userId)
  265. )
  266. : filter === "exchange"
  267. ? renderExchange(data.exchange)
  268. : allocationsTable(
  269. (filterAllocations((data.allocations || []).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)), filter, userId)),
  270. userId
  271. )
  272. )
  273. );
  274. module.exports = { renderBankingView };