Files
archy/docker/justworks/business/public/assets/modules.js
T
yaya 94899e69d3 feat: integrate Just Works app and native business setup
Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
2026-10-08 19:39:38 +01:00

29 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {frame as shellFrame} from './ds/frame.js';
const ids=['business','pos','order','pay','payandgo','screen','gallery','events'];
const query=new URLSearchParams(location.search);
const npub=query.get('npub')?.trim()||'';
const host=location.hostname.split('.')[0];
const surface=ids.includes(host)?host:(query.get('surface')||'pos');
const prod=location.hostname.endsWith('justworks.cash');
const link=id=>prod?`https://${id}.justworks.cash/`:`/portal?surface=${id}`;
const imgs=['https://justworks.cash/assets/examples/tiles/localpub-venue-1.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-3.webp','https://justworks.cash/assets/examples/tiles/localpub-venue-5.webp'];
const app=document.querySelector('#app');let state;
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const frame=(html,label=surface,nav=true)=>shellFrame(app,{html,label,surface,nav});
function off(id){const m=state.modules[id];return m&&!m.enabled?`<section class="center"><p class="eyebrow">Module disabled</p><h1>${id} is currently off.</h1><p>The merchant can activate it in Business.</p><a href="${link('business')}">Open Business</a></section>`:''}
function sample(){return `<article class="queue-item"><i>08</i><div><strong>Table 8</strong><small>2× Coral · 1× Nikita</small></div><time>now</time><button>Accept</button></article><article class="queue-item"><i>⌁</i><div><strong>Anna · 4 guests</strong><small>Arriving in 10 minutes</small></div><time>18:40</time><button>Seat</button></article>`}
function orderCard(o){return `<article class="queue-item"><i>${o.table_number}</i><div><strong>Table ${o.table_number}</strong><small>${o.items.map(x=>`${x.qty}× ${esc(x.name)}`).join(' · ')}</small></div><time>${new Date(o.created_at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time><button data-order="${o.id}">Accept</button></article>`}
function pos(){const orders=state.orders||[];frame(`<header class="head"><div><p class="eyebrow">LocalPub Madeira · Staff tablet</p><h1>Service, without the shouting.</h1></div><span class="online">● Live</span></header><section class="metrics"><article><span>Open orders</span><strong>${orders.length}</strong><small>Order to table</small></article><article><span>Bookings</span><strong>4</strong><small>2 arriving soon</small></article><article><span>Paid today</span><strong>286k</strong><small>sats settled</small></article></section><div class="pos-grid"><section class="panel"><p class="eyebrow">Live queue</p><h2>Orders & bookings</h2><div class="queue">${orders.length?orders.map(orderCard).join(''):sample()}</div></section><aside class="panel quick"><p class="eyebrow">New walk-in</p><h2>Start a service</h2><a href="${link('order')}"><b>↗</b><span><strong>New order</strong><small>Table order and tab</small></span></a><a href="${link('pay')}"><b>€</b><span><strong>Pay an order</strong><small>Find table or receipt</small></span></a><a href="${link('payandgo')}"><b>€</b><span><strong>Just Pay</strong><small>Enter receipt total</small></span></a><button><b>□</b><span><strong>Book a table</strong><small>Now or later</small></span></button></aside></div>`,'POS');document.querySelectorAll('[data-order]').forEach(b=>b.onclick=async()=>{await fetch(`/api/orders/${b.dataset.order}/status`,{method:'POST',headers:{'content-type':'application/json'},body:'{"status":"accepted"}'});boot()})}
function euro(cents){return `€${(cents/100).toFixed(2)}`}
function orderTotal(order){return (order?.items||[]).reduce((sum,item)=>sum+Number(item.price_cents||0)*Number(item.qty||0),0)}
function invoiceView(payment){return `<section class="invoice-view" aria-live="polite"><p class="eyebrow">Invoice ready</p><h2>${payment.amount_sats.toLocaleString()} sats</h2><div class="invoice-qr">${payment.qr_svg}</div><a class="open-wallet" href="lightning:${esc(payment.bolt11)}">Open Lightning wallet <span>↗</span></a><button class="copy-invoice" type="button">Copy invoice</button><div class="settlement-wait"><i></i><strong>Waiting for payment</strong><small>${payment.verifiable?'This page checks Lightning automatically.':'Keep this page open while the merchant confirms.'}</small></div><button class="check-payment" type="button">Check payment now</button></section>`}
function paidView(payment){const proof=payment.receipt_id?`<a class="payment-proof" href="https://njump.me/${esc(payment.receipt_id)}" target="_blank" rel="noopener"><span>Verified Lightning proof</span><code>${esc(payment.receipt_id.slice(0,12))}…${esc(payment.receipt_id.slice(-8))}</code><b>↗</b></a>`:'<p class="payment-proof unavailable">Payment confirmed by the provider</p>';return `<section class="paid-view" role="status" aria-live="assertive"><div class="paid-mark">✓</div><p class="eyebrow">Lightning settled</p><h2>PAID</h2><div class="paid-amount"><strong>${euro(payment.amount_cents)}</strong><span>${payment.amount_sats.toLocaleString()} sats</span></div><p class="paid-merchant">Paid to <strong>${esc(payment.merchant_name)}</strong></p>${proof}<div class="paid-meta"><span>Confirmed</span><time>${new Date(payment.settled_at||Date.now()).toLocaleString([],{dateStyle:'medium',timeStyle:'short'})}</time></div><p class="show-staff">Show this screen to the merchant</p></section>`}
async function createQuickInvoice(amountCents,comment){const response=await fetch('/api/quick-pay',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({npub,amount_cents:amountCents,comment})});const value=await response.json();if(!response.ok)throw new Error(value.error||'Invoice unavailable');return value}
async function paymentStatus(id){const response=await fetch(`/api/quick-pay-status?npub=${encodeURIComponent(npub)}&id=${encodeURIComponent(id)}`,{cache:'no-store'});const value=await response.json();if(!response.ok)throw new Error(value.error||'Payment status unavailable');return value}
function pay(go=false){const id=go?'payandgo':'pay';if(off(id)){frame(off(id),go?'Just Pay':'Pay Bill');return}const merchant=state.merchant||{},profile=state.profile||{},orders=state.orders||[];let pollTimer;const initialTable=query.get('table')||'';frame(`<section class="pay-shell quick-pay"><div class="pay-copy"><p class="eyebrow">${go?'Pay & go':'Pay your table'}</p><h1>${go?'Receipt in. Wallet out.':'Find it. Scan it. Paid.'}</h1><p>${go?'Enter the exact receipt total and pay. No account.':'Enter your table number. We find the open order and create one Lightning invoice.'}</p></div><div class="pay-card"><div class="merchant"><span>${esc((merchant.name||'JW').split(/\s+/).slice(0,2).map(x=>x[0]).join('').toUpperCase())}</span><div><strong>${esc(merchant.name||'Merchant')}</strong><small>${esc(profile.lud16||state.lightning_address||'Lightning')}</small></div></div><label for="payAmount">${go?'Receipt total':'Table number'}</label><div class="amount"><span>${go?'€':'#'}</span><input id="payAmount" inputmode="${go?'decimal':'numeric'}" autocomplete="off" placeholder="${go?'0.00':'Table'}" value="${esc(initialTable)}"><small id="amountHint">${go?'Add the amount printed on the receipt.':'Enter your table to find its open order.'}</small></div>${go?'':`<div id="receipt" class="receipt hidden"></div>`}<button id="payButton" class="primary" type="button">Generate invoice <span>→</span></button><p id="payStatus" class="secure">QR code + one-tap wallet link</p><div id="invoiceResult"></div></div></section>`,go?'Just Pay':'Pay Bill');const input=document.querySelector('#payAmount'),button=document.querySelector('#payButton'),status=document.querySelector('#payStatus'),receipt=document.querySelector('#receipt'),result=document.querySelector('#invoiceResult');let selectedOrder=null;const findOrder=()=>{if(go)return;const table=Number.parseInt(input.value,10);selectedOrder=orders.find(order=>Number(order.table_number)===table&&order.status!=='closed')||null;const cents=orderTotal(selectedOrder);receipt.classList.toggle('hidden',!selectedOrder);receipt.innerHTML=selectedOrder?`<span>${selectedOrder.items.map(item=>`${item.qty}× ${esc(item.name)}`).join(' · ')}</span><strong>${euro(cents)}</strong>`:'';document.querySelector('#amountHint').textContent=selectedOrder?(cents?`Open order · ${euro(cents)}`:'This order has no priced items.'):'No open order found for this table.'};if(!go){input.addEventListener('input',findOrder);if(initialTable)findOrder()}button.onclick=async()=>{let cents;if(go){const raw=Number.parseFloat(input.value.replace(',','.'));if(!Number.isFinite(raw)||raw<=0){status.textContent='Enter the receipt total.';status.classList.add('error');input.focus();return}cents=Math.round(raw*100)}else{findOrder();cents=orderTotal(selectedOrder);if(!selectedOrder||!cents){status.textContent=selectedOrder?'This order has no payable amount.':'Enter a table with an open order.';status.classList.add('error');input.focus();return}}button.disabled=true;button.innerHTML='Creating invoice…';status.textContent='Connecting to the merchant’s Lightning address…';status.classList.remove('error');try{const payment=await createQuickInvoice(cents,go?'Pay & Go':`Table ${selectedOrder.table_number}`);result.innerHTML=invoiceView(payment);document.querySelector('.copy-invoice').onclick=async event=>{await navigator.clipboard.writeText(payment.bolt11);event.currentTarget.textContent='Copied ✓'};button.classList.add('hidden');status.textContent=`${euro(payment.amount_cents)} · ready to pay`;const check=async()=>{try{const current=await paymentStatus(payment.id);if(current.status!=='paid')return;clearInterval(pollTimer);status.classList.add('hidden');result.innerHTML=paidView(current);document.querySelector('.paid-view').scrollIntoView({behavior:'smooth',block:'center'})}catch(error){status.textContent='Still checking Lightning…'}};document.querySelector('.check-payment').onclick=check;if(payment.verifiable)pollTimer=setInterval(check,2500);document.querySelector('.invoice-view').scrollIntoView({behavior:'smooth',block:'nearest'})}catch(error){status.textContent=error.message;status.classList.add('error');button.disabled=false;button.innerHTML='Try again <span>→</span>'}}}
function screen(){if(off('screen')){frame(off('screen'),'Screen');return}frame(`<section class="signage"><img src="${imgs[0]}"><div class="shade"></div><header><span data-logo="icon"></span><b>LocalPub Madeira</b><time>${new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</time></header><div class="sign-copy"><p>Tonight in Madeira</p><h1>Fresh poncha.<br>Cold Coral.<br>Good people.</h1><div class="ticker"><span>21,000 sats tipped</span><span>Table 8 · order confirmed</span><span>Next · Sunset Sessions</span></div></div><aside><span>Scan to order</span><div>▦</div><strong>order.justworks.cash</strong></aside></section>`,'Screen',false)}
const card=(name,type,img,rating)=>`<article class="merchant-card"><figure><img src="${img}"><span>Open now</span></figure><div><p>${type}</p><h2>${name}</h2><small>★ ${rating} · Bitcoin accepted</small><footer><span>Order</span><span>Book</span><span>Pay</span></footer></div></article>`;
function gallery(){if(off('gallery')){frame(off('gallery'),'Gallery');return}frame(`<header class="gallery-head"><p class="eyebrow">Discover Madeira</p><h1>Places that just work.</h1><p>Bitcoin-friendly merchants, without the map.</p><div class="filters"><button class="active">All places</button><button>Open now</button><button>Food & drink</button><button>Stay</button><button>Shop</button><button>Lightning</button></div></header><section class="gallery-grid">${card('LocalPub Madeira','Bar · Funchal',imgs[0],'4.8')}${card('Barreirinha Bar Café','Café · Old Town',imgs[1],'4.7')}${card('Madeira Sunset Lodge','Stay · Ponta do Sol',imgs[2],'4.9')}${card('Mercado Local','Market · Funchal',imgs[0],'4.6')}</section>`,'Gallery');document.querySelectorAll('.filters button').forEach(b=>b.onclick=()=>{document.querySelectorAll('.filters button').forEach(x=>x.classList.remove('active'));b.classList.add('active')})}
function events(){frame(`<section class="center"><span class="orbit">⌁</span><p class="eyebrow">Nostr-native discovery</p><h1>Events are coming soon.</h1><p>Signed events from Just Works merchants—shown like the gallery, owned by the people publishing them.</p><div class="tags"><span>Merchant events</span><span>Nostr signed</span><span>Local discovery</span></div><a href="${link('gallery')}">Explore merchants instead →</a></section>`,'Events')}
async function boot(){if(surface==='order')return location.replace('/nsite'+location.search);if(surface==='business')return location.replace(link('business'));if(['pay','payandgo'].includes(surface)){if(!npub)throw new Error('Merchant missing');const [business,posState]=await Promise.all([fetch(`/api/business?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v}),fetch(`/api/pay-orders?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const v=await r.json();if(!r.ok)throw new Error(v.error);return v})]);state={...business,...posState};return pay(surface==='payandgo')}state=await fetch('/api/dashboard',{cache:'no-store'}).then(r=>r.json());({pos,screen,gallery,events}[surface]||pos)()}boot().catch(error=>frame(`<section class="center"><h1>Payment temporarily unavailable.</h1><p>${esc(error.message)}</p></section>`));