Files
archy/docker/justworks/business/public/assets/pos.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

24 lines
9.8 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 {mountAppControls} from './app-mode.js?v=1';
import {mountLogo} from './ds/logo.js';
import {mountMobileChrome} from './ds/frame.js';
import {npubFrom} from './ds/env.js';
const app=document.querySelector('#app');const params=new URLSearchParams(location.search);const npub=npubFrom();let merchant,state,timer,busy=false,lastIds='',lastBookingIds='';
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const ago=stamp=>{const s=Math.max(0,Math.floor((Date.now()-new Date(stamp).getTime())/1000));return s<60?`${s}s`:s<3600?`${Math.floor(s/60)}m`:`${Math.floor(s/3600)}h`};
function error(message){app.innerHTML=`<section class="pos-error"><span>⌁</span><p>Just Works POS</p><h1>${esc(message)}</h1><small>Use pos.justworks.cash/?npub=npub1…</small></section>`}
function shell(){app.innerHTML=`<header class="topbar" data-topbar><div class="brand"><span data-logo="icon"></span><b>Just Works<small>Counter POS</small></b></div><div class="merchant"><strong>${esc(merchant.name)}</strong><small>${esc([merchant.category,merchant.area].filter(Boolean).join(' · '))}</small></div><div class="connection" id="connection"><i></i><span>Live</span><time id="clock"></time></div></header><div class="pos-app-controls"></div><section class="stats"><article><span>Waiting</span><strong id="waitingCount">0</strong></article><article><span>Preparing</span><strong id="preparingCount">0</strong></article><article><span>Ready</span><strong id="readyCount">0</strong></article><article><span>Payments</span><strong id="paymentCount">0</strong></article></section><nav class="tabs"><button class="active" data-tab="orders">Orders <span id="orderBadge">0</span></button><button data-tab="payments">Payments <span id="payBadge">0</span></button><button data-tab="bookings">Bookings <span id="bookingBadge">0</span></button></nav><section id="orders" class="board active"></section><section id="payments" class="board"></section><section id="bookings" class="board"></section><div id="toast" class="toast"></div>`;document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>showTab(b.dataset.tab));document.querySelectorAll('[data-logo]').forEach(host=>mountLogo(host,{icon:true}));mountMobileChrome(app);mountAppControls(document.querySelector(".pos-app-controls"));if(location.hash==='#bookings')showTab('bookings');tick();setInterval(tick,10000)}
function tick(){const c=document.querySelector('#clock');if(c)c.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}
function showTab(id){document.querySelectorAll('[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab===id));document.querySelectorAll('.board').forEach(b=>b.classList.toggle('active',b.id===id))}
function orderCard(order){const next={new:['Accept','accepted'],accepted:['Mark delivered','delivered'],delivered:['Close table','closed']}[order.status];const request=order.items.map(item=>`${item.qty>1?`${item.qty}× `:''}${esc(item.name)}`).join(' · ');return `<article class="ticket status-${order.status}" data-ticket="${order.id}"><header><span class="table">${order.table_number}</span><div><small>Table</small><strong>${order.table_number}</strong></div><time data-age="${order.created_at}">${ago(order.created_at)}</time></header><div class="ticket-body"><p>${request}</p>${order.note?`<small>${esc(order.note)}</small>`:''}</div><footer><span class="status">${order.status==='new'?'Waiting':order.status==='accepted'?'Preparing':'Delivered'}</span>${next?`<button data-order="${order.id}" data-status="${next[1]}">${next[0]} <b>→</b></button>`:''}</footer></article>`}
function paymentCard(payment){return `<article class="payment-ticket"><header><span>€</span><div><small>Table ${payment.table_number}</small><strong>${payment.amount_sats.toLocaleString()} sats</strong></div></header><p>€${(payment.amount_cents/100).toFixed(2)} · ${payment.status}</p>${payment.status==='paid'?`<button data-payment="${payment.id}">Acknowledge payment ✓</button>`:'<span class="pending">Waiting for Lightning settlement…</span>'}</article>`}
function bookingCard(booking){const next={new:['Confirm booking','confirmed'],confirmed:['Mark seated','seated'],seated:['Close','closed']}[booking.status];const time=new Date(booking.scheduled_at);return `<article class="ticket booking-ticket status-${booking.status}"><header><span class="table">${booking.guests}</span><div><small>${booking.mode==='now'?'Arriving soon':'Reserved'}</small><strong>${time.toLocaleDateString([],{weekday:'short',day:'numeric',month:'short'})} · ${time.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}</strong></div><time>${ago(booking.created_at)}</time></header><div class="ticket-body"><p>${esc(booking.guest_name)} · ${booking.guests} ${booking.guests===1?'guest':'guests'}</p>${booking.notes?`<small>${esc(booking.notes)}</small>`:''}</div><footer><span class="status">${booking.status}</span>${next?`<button data-booking="${booking.id}" data-status="${next[1]}">${next[0]} <b>→</b></button>`:''}</footer></article>`}
function render(){const orders=state.orders||[],payments=state.payments||[],bookings=state.bookings||[];document.querySelector('#waitingCount').textContent=orders.filter(o=>o.status==='new').length;document.querySelector('#preparingCount').textContent=orders.filter(o=>o.status==='accepted').length;document.querySelector('#readyCount').textContent=orders.filter(o=>o.status==='delivered').length;document.querySelector('#paymentCount').textContent=payments.length;document.querySelector('#orderBadge').textContent=orders.length;document.querySelector('#payBadge').textContent=payments.length;document.querySelector('#bookingBadge').textContent=bookings.length;document.querySelector('#orders').innerHTML=orders.length?orders.map(orderCard).join(''):`<div class="empty"><span>✓</span><h2>No open orders.</h2><p>New table orders appear here automatically.</p></div>`;document.querySelector('#payments').innerHTML=payments.length?payments.map(paymentCard).join(''):`<div class="empty"><span>€</span><h2>No pending payments.</h2><p>Pay Bill and Just Pay confirmations appear here.</p></div>`;document.querySelector('#bookings').innerHTML=bookings.length?bookings.map(bookingCard).join(''):`<div class="empty"><span>□</span><h2>No upcoming bookings.</h2><p>Table reservations appear here automatically.</p></div>`;document.querySelectorAll('[data-order]').forEach(b=>b.onclick=()=>updateOrder(b.dataset.order,b.dataset.status));document.querySelectorAll('[data-payment]').forEach(b=>b.onclick=()=>ack(b.dataset.payment));document.querySelectorAll('[data-booking]').forEach(b=>b.onclick=()=>updateBooking(b.dataset.booking,b.dataset.status));const ids=orders.map(o=>o.id).join(',');if(lastIds&&ids!==lastIds&&orders.some(o=>!lastIds.includes(o.id)))notify('New order received');lastIds=ids;const bookingIds=bookings.map(item=>item.id).join(',');if(lastBookingIds&&bookingIds!==lastBookingIds&&bookings.some(item=>!lastBookingIds.includes(item.id)))notify('New booking received');lastBookingIds=bookingIds}
function notify(text){const t=document.querySelector('#toast');t.textContent=text;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),1800)}
async function updateOrder(id,status){if(busy)return;busy=true;try{const r=await fetch(`/api/orders/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!r.ok)throw new Error();notify(status==='accepted'?'Order accepted':status==='delivered'?'Marked delivered':'Table closed');await load()}catch{notify('Could not update order')}finally{busy=false}}
async function ack(id){if(busy)return;busy=true;try{const response=await fetch(`/api/payments/${id}/acknowledge`,{method:'POST'});if(!response.ok)throw new Error();notify('Payment cleared');await load()}catch{notify('Could not acknowledge payment. Check your login.')}finally{busy=false}}
async function updateBooking(id,status){if(busy)return;busy=true;try{const response=await fetch(`/api/bookings/${id}/status`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({status})});if(!response.ok)throw new Error();notify(status==='confirmed'?'Booking confirmed':status==='seated'?'Guests seated':'Booking closed');await load()}catch{notify('Could not update booking')}finally{busy=false}}
function unavailable(message){const connection=document.querySelector('#connection');connection?.classList.add('offline');connection?.querySelector('span').replaceChildren(message);if(!document.querySelector('#orders'))return;state={orders:[],payments:[],bookings:[]};render();document.querySelector('#orders').innerHTML=message==='Login required'?'<div class="empty"><h2>Merchant login required</h2><p>Use Login above to connect your Business session.</p></div>':'<div class="empty"><h2>Waiting for connection</h2><p>Live orders and payments are unavailable. Nothing is saved for later.</p></div>'}
window.addEventListener('offline',()=>unavailable('Offline'));
async function load(){const connection=document.querySelector('#connection');try{if(!navigator.onLine)throw new Error('Offline');const response=await fetch(`/api/pos?npub=${encodeURIComponent(npub)}`,{cache:'no-store'});if(!response.ok)throw new Error(response.status===403?'Login required':'Reconnecting');const data=await response.json();if(!navigator.onLine)throw new Error('Offline');state=data;render();connection?.classList.remove('offline');connection?.querySelector('span').replaceChildren('Live')}catch(error){unavailable(error.message==='Login required'?'Login required':navigator.onLine?'Reconnecting':'Offline')}}
async function boot(){if(!npub)return error('Add the merchant npub to start.');const data=await fetch(`/api/order-merchant?npub=${encodeURIComponent(npub)}`,{cache:'no-store'}).then(async r=>{const d=await r.json();if(!r.ok)throw new Error(d.error);return d});merchant=data.merchant;shell();tick();await load();timer=setInterval(load,1500)}boot().catch(e=>error(e.message||'Merchant unavailable'));