duly / web/app.js · raw
import { copy, h, mount } from './ui.js';import { NET, base, isTest, view } from './api.js';import { payPage } from './pay.js';import { appPage } from './merchant.js';import { formatAmount } from './lib/money.js';// The hero pay window plays the real payment of invoice 1-3 once: Base is picked, the quote// shows, the route pulses Base -> NEAR Intents -> dulypay.near while the clock runs to the// recorded 31.8 s, the stamp lands and the two packets fill. boot.js sets .motion (never under// reduced motion); the page as served is the finished state, so nothing is lost without it.const STEPS = [  ['0', 0],  ['1', 700],  ['1b', 1300],  ['2', 2000],  ['3', 2950],  ['4', 3900],  ['done', 4900],];function playHero() {  const win = document.getElementById('proof-inv');  if (!win) return;  const chip = document.getElementById('proof-chip');  const clock = document.getElementById('proof-clock');  const finalChip = chip?.textContent || '';  const finalClock = clock?.textContent || '';  const secs = parseFloat(finalClock) || 0;  let timers = [];  let raf = 0;  const stop = () => {    timers.forEach(clearTimeout);    timers = [];    cancelAnimationFrame(raf);  };  const finish = () => {    stop();    win.dataset.step = 'done';    if (chip) { chip.textContent = finalChip; chip.classList.remove('due'); chip.classList.add('good'); }    if (clock) clock.textContent = finalClock;    setAmounts(true);  };  // The clock is a true figure: it runs from 0 to the recorded seconds while the pulse travels.  const runClock = (ms) => {    const t0 = performance.now();    const tick = (now) => {      const k = Math.min(1, (now - t0) / ms);      clock.textContent = `${(secs * (1 - (1 - k) ** 2)).toFixed(1)} s`;      if (k < 1) raf = requestAnimationFrame(tick);    };    raf = requestAnimationFrame(tick);  };  const amounts = [...win.querySelectorAll('.packet b[data-final]')];  const setAmounts = (paid) => amounts.forEach((b) => { b.textContent = paid ? b.dataset.final : '0.00 USDC'; });  const play = () => {    stop();    setAmounts(false);    if (chip) { chip.textContent = 'Due'; chip.classList.remove('good'); chip.classList.add('due'); }    if (clock) clock.textContent = '0.0 s';    for (const [step, at] of STEPS) {      timers.push(setTimeout(() => {        if (step === '1b') { win.dataset.step = '2pre'; return; }        win.dataset.step = step;        if (step === '2' && clock) runClock(1900);        if (step === '4') {          if (chip) { chip.textContent = finalChip; chip.classList.remove('due'); chip.classList.add('good'); }          setAmounts(true);        }        if (step === 'done') finish();      }, at));    }  };  document.getElementById('replay')?.addEventListener('click', () => {    if (!document.documentElement.classList.contains('motion')) return finish();    play();  });  // A reader who interacts with the window (copy, links) gets the finished state at once.  win.addEventListener('pointerdown', () => win.dataset.step !== 'done' && finish());  if (!document.documentElement.classList.contains('motion')) return;  window.__dulyMotion = true;  // Plays when the window is on screen; a phone shows it on the first screen, so this is at load.  let started = false;  const io = new IntersectionObserver((entries) => {    if (started || !entries.some((e) => e.isIntersecting)) return;    started = true;    io.disconnect();    play();  }, { threshold: 0.35 });  io.observe(win);}/** The how-it-works stage follows the step being read. Text never hides; only the stage changes. */function wireHow() {  const stage = document.querySelector('.how-stage .stage-in');  const steps = [...document.querySelectorAll('.how-steps li[data-step]')];  if (!stage || !steps.length) return;  const set = (n) => {    stage.dataset.active = n;    steps.forEach((li) => li.classList.toggle('on', li.dataset.step === n));  };  set('1');  if (!('IntersectionObserver' in window)) return;  const io = new IntersectionObserver((entries) => {    for (const e of entries) if (e.isIntersecting) set(e.target.dataset.step);  }, { rootMargin: matchMedia('(max-width: 1023px)').matches ? '-62% 0px -28% 0px' : '-40% 0px -50% 0px' });  steps.forEach((li) => io.observe(li));}/** Light or dark, remembered on this device when storage allows it. */function wireTheme() {  const btn = document.getElementById('theme-toggle');  if (!btn) return;  const root = document.documentElement;  const current = () => root.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');  const label = () => btn.setAttribute('aria-label', current() === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');  label();  btn.addEventListener('click', () => {    const next = current() === 'dark' ? 'light' : 'dark';    const apply = () => {      root.dataset.theme = next;      document.querySelectorAll('meta[name="theme-color"]').forEach((m) => m.setAttribute('content', next === 'dark' ? '#0c1322' : '#f2f4f0'));      label();    };    apply();    try {      localStorage.setItem('duly-theme', next);    } catch {      // Not remembered when storage is blocked; the choice still holds for this page.    }  });  matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change', label);}/** Copy buttons on server-rendered receipts carry the full value in data-copy. */function wireCopy() {  document.addEventListener('click', (e) => {    const btn = e.target.closest?.('button.copy[data-copy]');    if (!btn) return;    copy(btn.dataset.copy, 'Copied');    btn.classList.add('done');    setTimeout(() => btn.classList.remove('done'), 1200);  });}async function landing() {  // The server already rendered the landing, evidence included. Keep it as it is.  if (document.querySelector('#app [data-ssr="landing"]')) {    wireCopy();    wireTheme();    wireHow();    return playHero();  }  const totals = h('p', { class: 'muted small' });  mount(    h(      'div',      {},      isTest && h('div', { class: 'testbar' }, 'Test mode. No real money.'),      h('header', { class: 'top' }, h('a', { class: 'brand', href: `${base}/` }, 'Duly'), h('a', { class: 'btn', href: isTest || NET.live ? `${base}/app` : '/test/app' }, 'Open')),      h(        'main',        { class: 'wrap stack' },        h(          'section',          { class: 'hero' },          h('h1', {}, 'Send an invoice. Get paid from any chain.'),          h('p', {}, 'Your client pays with whatever they hold. You receive USDC, your tax share is set aside before you can spend it, and your accountant gets a clean export. No wallet to install, and no gas to buy.'),          h('div', { class: 'row cta' }, h('a', { class: 'btn primary', href: isTest || !NET.live ? '/test/app' : '/app' }, isTest || !NET.live ? 'Try it with test money' : 'Start'), !isTest && NET.live && h('a', { class: 'btn', href: '/test/app' }, 'Try it with test money')),        ),        h(          'ol',          { class: 'how' },          h('li', { class: 'card' }, h('h3', {}, 'Send a link'), h('p', { class: 'muted small' }, 'Sign in with your fingerprint or face, add line items and share the link.')),          h('li', { class: 'card' }, h('h3', {}, 'Client pays their way'), h('p', { class: 'muted small' }, 'Any major token on any major chain. NEAR Intents converts it, and the invoice closes itself on-chain.')),          h('li', { class: 'card' }, h('h3', {}, 'It sorts itself out'), h('p', { class: 'muted small' }, 'Tax and subcontractor shares move in the same step. Export a CSV your accountant can open.')),        ),        totals,      ),    ),  );  const t = await view('get_totals').catch(() => null);  if (t) totals.textContent = `${t.invoices} invoices and ${formatAmount(t.received)} USDC settled through contract ${NET.contract}.`;}function notLiveYet() {  mount(    h(      'div',      {},      h('header', { class: 'top' }, h('a', { class: 'brand', href: '/' }, 'Duly')),      h('main', { class: 'wrap narrow' }, h('section', { class: 'card stack' }, h('h2', {}, 'Live payments are not open yet'), h('p', { class: 'muted' }, 'Do not send money to anyone using this link. You can try everything with test money.'), h('a', { class: 'btn primary', href: '/test/app' }, 'Try it with test money'))),    ),  );}async function route() {  const path = location.pathname.replace(/^\/test(?=\/|$)/, '') || '/';  const pay = path.match(/^\/i\/(\d{1,10}-\d{1,10})$/);  // Live routes need a pinned code hash, so the site can never trust an unverified contract.  const open = NET.live && (isTest || !!NET.codeHash);  if (!open && (pay || path === '/app' || path.startsWith('/app/'))) return notLiveYet();  if (pay) return payPage(pay[1]);  if (path === '/app' || path.startsWith('/app/')) return appPage(path);  return landing();}route().catch((e) => {  console.error(e);  mount(h('main', { class: 'wrap narrow' }, h('div', { class: 'card' }, h('h2', {}, 'Something went wrong'), h('p', { class: 'muted' }, 'Please reload the page.'))));});