git / moonbite-network / static/wallet-flagship-ui.js · raw

/* MoonBite flagship wallet — presentation layer ("Obsidian"). * Pure UI: the metal card (balance display, tilt, light), live network strip, * number pad, review sheet, receipt, recovery-phrase veil, hide-balance, PIN * shake, paste/share. It never touches keys: all wallet logic stays in * wallet-flagship.js, which this file only drives through the same inputs and * data-act buttons a person would press, plus its 'mb:sent' event. */const $ = s => document.querySelector(s);const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;const haptic = (ms = 8) => {  try{    const ua = navigator.userActivation;    if(navigator.vibrate && (!ua || ua.hasBeenActive)) navigator.vibrate(ms);  }catch(e){}};const onActive = (id, fn) => {  const el = document.getElementById(id); if(!el) return;  new MutationObserver(() => { if(el.classList.contains('active')) fn(); })    .observe(el, { attributes: true, attributeFilter: ['class'] });};function setText(el, text){ if(el) el.textContent = text; }/* ---------- balance on the card ---------- * wallet-flagship.js writes the formatted total into #balNum; mirror it into * the card with the decimals dimmed and the size fitted to the card. */const balNum = $('#balNum'), balView = $('#balView');function renderBalance(){  const raw = (balNum.textContent || '0').trim();  const i = raw.indexOf('.');  balView.textContent = '';  balView.append(document.createTextNode(i < 0 ? raw : raw.slice(0, i)));  if(i >= 0){ const d = document.createElement('span'); d.className = 'dec'; d.textContent = raw.slice(i); balView.append(d); }  const n = raw.length;  balView.style.setProperty('--bal-size', (n <= 7 ? 46 : n <= 10 ? 38 : n <= 13 ? 31 : 25) + 'px');}new MutationObserver(renderBalance).observe(balNum, { childList: true, characterData: true, subtree: true });renderBalance();/* ---------- hide balance (per-device preference) ---------- */function setHidden(on){  document.body.classList.toggle('hide-bal', on);  const ic = $('#hideIc use'); if(ic) ic.setAttribute('href', on ? '#i-eye-off' : '#i-eye');  try{ localStorage.setItem('mbf_hide_bal', on ? '1' : '0'); }catch(e){}}try{ setHidden(localStorage.getItem('mbf_hide_bal') === '1'); }catch(e){}/* ---------- card: tilt + light that follows the pointer ---------- */const card = $('#mcard');if(card && !reduceMotion){  card.addEventListener('pointermove', e => {    const r = card.getBoundingClientRect();    const x = (e.clientX - r.left) / r.width, y = (e.clientY - r.top) / r.height;    card.classList.add('live');    card.style.setProperty('--ry', ((x - .5) * 12).toFixed(2) + 'deg');    card.style.setProperty('--rx', ((.5 - y) * 10).toFixed(2) + 'deg');    card.style.setProperty('--mx', (x * 100).toFixed(1) + '%');    card.style.setProperty('--my', (y * 100).toFixed(1) + '%');  });  card.addEventListener('pointerleave', () => {    card.classList.remove('live');    ['--rx','--ry'].forEach(p => card.style.setProperty(p, '0deg'));    card.style.setProperty('--mx', '22%'); card.style.setProperty('--my', '0%');  });}/* ---------- live network strip (real data from the explorer API) ---------- */let tip = null;   // { height, ts }function ago(ts){  const s = Math.max(0, Math.floor(Date.now() / 1000 - ts));  if(s < 60) return s + 's ago';  if(s < 3600) return Math.floor(s / 60) + 'm ago';  if(s < 86400) return Math.floor(s / 3600) + 'h ago';  return Math.floor(s / 86400) + 'd ago';}function paintNet(){  const pill = $('#livePill');  if(!tip){ pill.classList.add('off'); setText($('#liveTxt'), 'Offline'); setText($('#nHeight'), '—'); setText($('#nAge'), '—'); return; }  pill.classList.remove('off');  const h = tip.height.toLocaleString('en-US');  $('#liveTxt').innerHTML = ''; const b = document.createElement('b'); b.textContent = '#' + h;  $('#liveTxt').append(b, document.createTextNode(' · ' + ago(tip.ts)));  setText($('#nHeight'), h); setText($('#nAge'), ago(tip.ts));}async function pollNet(){  if(document.visibilityState !== 'visible') return;  try{    const r = await fetch('/api/explorer/blocks?limit=1', { cache: 'no-store' });    const j = await r.json(); const blk = j && j.blocks && j.blocks[0];    tip = (r.ok && blk && Number.isFinite(blk.height)) ? { height: blk.height, ts: blk.timestamp } : null;  }catch(e){ tip = null; }  paintNet();}onActive('s-home', pollNet);setInterval(pollNet, 30000);setInterval(() => { if(tip) paintNet(); }, 5000);   // keep "x ago" honest between polls/* ---------- recovery phrase: veil + acknowledgement gate ---------- */const seedWrap = $('#seedWrap'), ack = $('#seedAck'), toPin = $('#toPinBtn');function resetBackup(){  seedWrap.classList.add('veiled');  ack.classList.remove('on'); ack.classList.add('off'); ack.setAttribute('aria-checked', 'false');  toPin.disabled = true;}function revealSeed(){ seedWrap.classList.remove('veiled'); ack.classList.remove('off'); haptic(); }function toggleAck(){  if(ack.classList.contains('off')) return;  const on = !ack.classList.contains('on');  ack.classList.toggle('on', on); ack.setAttribute('aria-checked', String(on));  toPin.disabled = !on; haptic();}onActive('s-create', resetBackup);/* ---------- PIN: shake the dots when a fresh error appears ---------- */[['pinErr','setDots'],['unlockErr','unlockDots'],['revealErr','revealDots'], ['cpCurErr','cpCurDots'],['cpNewErr','cpNewDots']].forEach(([e, d]) => {  const err = document.getElementById(e), dots = document.getElementById(d);  if(!err || !dots) return;  let had = false;   // not on every countdown tick  new MutationObserver(() => {    const has = !!err.textContent.trim(), fresh = has && !had; had = has;    if(!fresh) return;    dots.classList.remove('shake'); void dots.offsetWidth; dots.classList.add('shake'); haptic(40);  }).observe(err, { childList: true, characterData: true, subtree: true });});document.addEventListener('pointerdown', e => { if(e.target.closest('.key[data-k]')) haptic(); });/* ---------- send: number pad ---------- */const amt = $('#sendAmt'), stage = $('#amtStage'), sendBtn = $('#sendBtn'), next = $('#sendNext');const isUnits = () => { try{ return localStorage.getItem('mbf_display_unit') === 'units'; }catch(e){ return false; } };function fire(){ amt.dispatchEvent(new Event('input', { bubbles: true })); }function fitAmount(){  const n = (amt.value || '0').length;  amt.style.setProperty('--amt-size', (n <= 5 ? 68 : n <= 8 ? 54 : n <= 11 ? 42 : 32) + 'px');}function overBalance(){  const avail = parseFloat(($('#sendAvail').textContent || '0').replace(/[^0-9.]/g, '')) || 0;  stage.classList.toggle('over', (parseFloat(amt.value) || 0) > avail);}function pressKey(k){  let v = amt.value;  if(k === 'del') v = v.slice(0, -1);  else if(k === '.'){    if(isUnits() || v.includes('.')) return;    v = (v || '0') + '.';  } else {    const dec = v.split('.')[1];    if(dec !== undefined && dec.length >= 8) return;      // 8 decimal places max    if(v.replace('.', '').length >= 16) return;    v = (v === '0') ? k : v + k;  }  amt.value = v; haptic(); fire();}$('#npad').innerHTML = ['1','2','3','4','5','6','7','8','9','.','0','del'].map(k => k === 'del'  ? '<button type="button" class="nk" data-nk="del" aria-label="Delete"><svg class="icon"><use href="#i-del"/></svg></button>'  : `<button type="button" class="nk" data-nk="${k}">${k}</button>`).join('');$('#npad').addEventListener('click', e => { const b = e.target.closest('[data-nk]'); if(b) pressKey(b.dataset.nk); });amt.addEventListener('input', () => {   // typed input (desktop): keep it numeric  const clean = amt.value.replace(isUnits() ? /[^0-9]/g : /[^0-9.]/g, '').replace(/(\..*)\./g, '$1');  if(clean !== amt.value){ amt.value = clean; fire(); return; }  fitAmount(); overBalance();});// Review mirrors the wallet's own validity check on the Confirm buttonnew MutationObserver(() => { if(!sheetOpen) next.disabled = sendBtn.disabled; })  .observe(sendBtn, { attributes: true, attributeFilter: ['disabled'] });onActive('s-send', () => { fitAmount(); overBalance(); });/* ---------- send: review sheet ---------- */const short = a => a.length > 22 ? a.slice(0, 11) + '…' + a.slice(-8) : a;function bigAmount(el, text, unit){  el.textContent = ''; el.append(document.createTextNode(text));  const sm = document.createElement('small'); sm.textContent = unit; el.append(sm);}let sheetOpen = false;function openSheet(){  if(next.disabled) return;  setText($('#rvTo'), short($('#sendTo').value.trim()));  bigAmount($('#sheetAmt'), amt.value || '0', isUnits() ? 'units' : 'MBITE');  setText($('#sendErr'), '');  sheetOpen = true; document.body.classList.add('sheet-open'); haptic(12);}function closeSheet(){ sheetOpen = false; document.body.classList.remove('sheet-open'); next.disabled = sendBtn.disabled; }new MutationObserver(() => { if(!$('#s-send').classList.contains('active')) closeSheet(); })  .observe($('#s-send'), { attributes: true, attributeFilter: ['class'] });document.addEventListener('keydown', e => {  if(e.key !== 'Escape') return;  if(sheetOpen) closeSheet(); else if($('#receipt').classList.contains('show')) closeReceipt();});/* ---------- receipt ---------- */let lastTx = '';document.addEventListener('mb:sent', e => {  const d = e.detail || {};  lastTx = d.txid || '';  bigAmount($('#rcAmt'), d.amountText || '', d.unit || 'MBITE');  setText($('#rcTo'), short(d.to || ''));  setText($('#rcFee'), (d.feeText || '') + ' ' + (d.unit || 'MBITE'));  setText($('#rcTx'), lastTx ? lastTx.slice(0, 10) + '…' + lastTx.slice(-6) : '—');  const r = $('#receipt'); r.classList.remove('show'); void r.offsetWidth; r.classList.add('show');  haptic([10, 60, 20]);});function closeReceipt(){ $('#receipt').classList.remove('show'); }async function copyTx(){  if(!lastTx) return;  try{ await navigator.clipboard.writeText(lastTx); flash('Transaction ID copied'); }catch(e){ flash('Copy failed'); }}function flash(msg){   // same toast element the wallet uses  setText($('#toastMsg'), msg); const t = $('#toast');  t.classList.add('show'); clearTimeout(t._u); t._u = setTimeout(() => t.classList.remove('show'), 1800);}/* ---------- paste / share ---------- */async function pasteTo(){  try{    const t = (await navigator.clipboard.readText() || '').trim();    if(t){ const el = $('#sendTo'); el.value = t; el.dispatchEvent(new Event('input', { bubbles: true })); haptic(); }  }catch(e){ $('#sendTo').focus(); }}const shareBtn = $('#shareBtn');if(navigator.share && shareBtn){ shareBtn.hidden = false; $('#recvBtns').classList.remove('one'); }async function shareAddr(){  const a = ($('#recvAddr').textContent || '').trim(); if(!a) return;  try{ await navigator.share({ title: 'My MoonBite address', text: a }); }catch(e){ /* dismissed */ }}/* ---------- delegation ---------- */const UI = {  toggleHide: () => { setHidden(!document.body.classList.contains('hide-bal')); haptic(); },  reveal: revealSeed, ack: toggleAck, openSheet, closeSheet, closeReceipt, copyTx,  paste: pasteTo, share: shareAddr, refresh: pollNet,};document.addEventListener('click', e => {  const el = e.target.closest('[data-ui]'); if(!el) return;  const fn = UI[el.dataset.ui]; if(fn) fn();});document.addEventListener('keydown', e => {  if(e.key !== 'Enter' && e.key !== ' ') return;  const el = e.target.closest('[data-ui="reveal"],[data-ui="ack"]'); if(!el) return;  e.preventDefault(); UI[el.dataset.ui]();});