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.')))); });