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]();});