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.
29 lines
13 KiB
JavaScript
29 lines
13 KiB
JavaScript
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>`));
|