/* 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'
? ''
: ``).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 button
new 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]();
});