From e2ca38a1088a0ec0595432726b2e708b14a811ea Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 21 Sep 2026 08:16:00 -0700 Subject: Point of sale: a bill, an optional tip prompt, one invoice, and a ledger that keeps tips apart. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new deferred feature (src/features/pos.js): type the amount in the display unit, optionally a note, Charge. With the tip prompt on, the customer sees the bill and picks a percentage (configurable, default 10/15/20), their own figure, or no tip; one Lightning invoice is made for the total through the Ark feature's receive (new hooks arkLnInvoice/arkLnWatch/arkLnUnwatch/ arkLnCancel wrap createLnInvoice and pollArkLn). Paid, expired and cancelled sales are recorded with the bill and the tip as separate figures, and the fiat rate at the time; the amount screen shows today's bills, tips and total. Doors: Settings → Payments card, and a button under the payment address on Receive (a receiveExtras hook the names pane renders). Co-Authored-By: Claude Fable 5.1 --- src/features/ark.js | 10 ++ src/features/index.js | 5 +- src/features/names.js | 3 +- src/features/pos.js | 264 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/i18n.js | 30 ++++++ src/style.css | 11 +++ 6 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/features/pos.js (limited to 'src') diff --git a/src/features/ark.js b/src/features/ark.js index 5327a23..ef5d8fc 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -3238,6 +3238,16 @@ export function arkFeature(ctx) { }, arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; }, arkReady() { return arkAvailable(); }, + // A Lightning invoice for another feature to watch (point of sale): + // the action id is the handle, the poller tells it when it settles. + async arkLnInvoice(amountSat, description) { + const mgr = await connectArk(); + const a = await mgr.createLnInvoice(amountSat, description); + return { id: a.id, invoice: a.invoice, paymentHash: a.paymentHash, amountSat, expiresAt: a.expiresAt }; + }, + arkLnWatch(id, onSettle) { pollArkLn(id, onSettle); return true; }, + arkLnUnwatch(id) { stopArkLnPoll(id); return true; }, + async arkLnCancel(id) { const mgr = await connectArk(); await mgr.cancelLnInvoice(id); return true; }, async arkMakeInvoice(amountSat, description) { const mgr = await connectArk(); const a = await mgr.createLnInvoice(amountSat, description); diff --git a/src/features/index.js b/src/features/index.js index a32bced..73369c3 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -22,14 +22,15 @@ import { syncFeature } from './sync.js'; import { messagesFeature } from './messages.js'; export function buildFeatures(ctx) { - return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx)]; + return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx), {}]; } export async function loadDeferredFeatures(ctx, features) { const late = []; await Promise.all([ import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }), import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }), - import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); }) + import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); }), + import('./pos.js').then((m) => { late.push(features[9] = m.posFeature(ctx)); }), ]); return late; } diff --git a/src/features/names.js b/src/features/names.js index f05ac09..0024847 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -588,7 +588,8 @@ export function namesFeature(ctx) { onClick: () => copy(addr), html: '', }))), - moreSection()); + moreSection(), + (ctx.hook && ctx.hook('receiveExtras')) || null); } // ---- BOLT 12 offers with a memo ---------------------------------------- diff --git a/src/features/pos.js b/src/features/pos.js new file mode 100644 index 0000000..c7e5882 --- /dev/null +++ b/src/features/pos.js @@ -0,0 +1,264 @@ +// Point of sale: a bill for a stated amount, an optional tip prompt for the +// customer (a few percentages, or their own figure), one Lightning invoice +// for the total — and a ledger that keeps the bill and the tip apart, so a +// day's tips can be counted on their own. +// +// Everything money-shaped goes through the Ark feature's Lightning receive +// (the same invoices the Receive tab hands out); this file is the flow and +// the ledger. Amounts are typed in the display unit (sats / BTC / your +// currency) and kept in sats, with the fiat rate at the time of the sale +// remembered so a receipt reads the same later. +import { qrSvg } from '../qr.js'; +import { rateNow, getCurrency, fmtFiat } from '../rates.js'; +import { t } from '../i18n.js'; + +export function posFeature(ctx) { + const { h, ui, render, wallet, toast, copy, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + + // ---- state --------------------------------------------------------------- + // { sales: [{ id, at, billSat, tipSat, tipPct, note, invoice, lnId, status: 'open'|'paid'|'void', fiat: { code, rate } }], + // askTip: true, tips: [10, 15, 20] } + const st = () => { + const s = wallet.loadFeatureState('pos', {}) || {}; + s.sales ||= []; + if (s.askTip == null) s.askTip = true; + if (!Array.isArray(s.tips) || !s.tips.length) s.tips = [10, 15, 20]; + return s; + }; + const save = (s) => { try { wallet.saveFeatureState('pos', s); } catch {} }; + const SALES_MAX = 500; + + const fiatLine = (sats, rate = rateNow(), code = getCurrency()) => (rate ? fmtFiat(sats, rate, code) : ''); + const pct = (sats, p) => Math.round(sats * p / 100); + const dayKey = (ms) => new Date(ms).toDateString(); + + // ---- flow ---------------------------------------------------------------- + // ui.pos: { step: 'amount'|'tip'|'invoice'|'paid', amount, note, tipMode, tipCustom, sale, error, busy } + function open() { + ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }; + render(); + } + function close() { + const p = ui.pos; + if (p && p.sale && p.sale.status === 'open') voidSale(p.sale); + ui.pos = null; + render(); + } + function billSat() { + const p = ui.pos; + const n = parseAmount(p.amount, getUnit()); + return n && n > 0 ? n : 0; + } + function charge() { + const p = ui.pos; + const bill = billSat(); + if (!bill) { p.error = t('posEnterAmount'); render(); return; } + p.error = ''; + if (!hook('arkReady')) { p.error = t('posNeedsSpending'); render(); return; } + if (st().askTip) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; } + makeSale(bill, 0, 0); + } + function tipSat() { + const p = ui.pos, bill = billSat(); + if (p.tipMode == null || p.tipMode === 'none') return 0; + if (p.tipMode === 'custom') { const n = parseAmount(p.tipCustom, getUnit()); return n && n > 0 ? n : 0; } + return pct(bill, p.tipMode); + } + async function makeSale(bill, tip, tipPct) { + const p = ui.pos; + p.busy = true; p.error = ''; render(); + const s = st(); + const sale = { + id: 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), + at: Date.now(), billSat: bill, tipSat: tip, tipPct, note: (p.note || '').trim().slice(0, 80), + status: 'open', fiat: rateNow() ? { code: getCurrency(), rate: rateNow() } : null, + }; + try { + const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' }); + const inv = await hook('arkLnInvoice', bill + tip, memo.slice(0, 120)); + if (!inv || !inv.invoice) throw new Error(t('posInvoiceFailed')); + sale.invoice = inv.invoice; sale.lnId = inv.id; + s.sales.push(sale); + if (s.sales.length > SALES_MAX) s.sales = s.sales.slice(-SALES_MAX); + save(s); + p.sale = sale; p.step = 'invoice'; + hook('arkLnWatch', inv.id, (a) => settle(sale, a)); + } catch (e) { + p.error = e.message || String(e); + } + p.busy = false; render(); + } + function settle(sale, action) { + const s = st(); + const row = s.sales.find((x) => x.id === sale.id); + const paid = action && action.step === 'done'; + if (row) { row.status = paid ? 'paid' : 'void'; if (paid) row.paidAt = Date.now(); save(s); } + if (ui.pos && ui.pos.sale && ui.pos.sale.id === sale.id) { + ui.pos.sale = row || sale; + ui.pos.step = paid ? 'paid' : 'amount'; + if (!paid) ui.pos.error = t('posInvoiceExpired'); + } + render(); + } + function voidSale(sale) { + const s = st(); + const row = s.sales.find((x) => x.id === sale.id); + if (row && row.status === 'open') { row.status = 'void'; save(s); } + if (sale.lnId) { hook('arkLnUnwatch', sale.lnId); Promise.resolve(hook('arkLnCancel', sale.lnId)).catch(() => {}); } + } + function newSale() { + const p = ui.pos; + if (p.sale && p.sale.status === 'open') voidSale(p.sale); + Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }); + render(); + } + + // ---- screens ------------------------------------------------------------- + const big = (text) => h('div', { class: 'pos-big' }, text); + const money = (sats, rate) => h('div', { class: 'col', style: 'align-items:center;gap:2px' }, + big(fmtAmount(sats) + ' ' + unitLabel()), + fiatLine(sats, rate) && getUnit() !== 'fiat' ? h('div', { class: 'muted' }, fiatLine(sats, rate)) : null); + const back = () => h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')); + + function amountScreen(p) { + const s = st(); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', { style: 'margin:0' }, t('posTitle')), + h('div', { class: 'input-group' }, + h('input', { + type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount, autofocus: true, + onInput: (e) => { p.amount = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') charge(); }, + }), + unitTag()), + getUnit() !== 'fiat' && billSat() && fiatLine(billSat()) ? h('div', { class: 'small muted', style: 'text-align:right' }, fiatLine(billSat())) : null, + h('input', { type: 'text', placeholder: t('posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }), + p.error ? h('div', { class: 'notice error small' }, p.error) : null, + h('button', { class: 'btn-primary btn-block', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t('posCharge'))), + tipSettingsCard(s), + salesCard(s), + back()); + } + function tipSettingsCard(s) { + return h('div', { class: 'card col', style: 'gap:8px' }, + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: !!s.askTip, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', + onChange: (e) => { const x = st(); x.askTip = e.target.checked; save(x); render(); } }), + h('span', {}, t('posAskTip'))), + s.askTip ? h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted', style: 'flex:0 0 auto' }, t('posTipChoices')), + h('input', { type: 'text', class: 'grow', value: s.tips.join(', '), inputmode: 'numeric', + onChange: (e) => { + const x = st(); + const list = [...new Set(e.target.value.split(/[\s,]+/).map((v) => parseInt(v, 10)).filter((n) => n > 0 && n <= 100))].slice(0, 5); + if (list.length) { x.tips = list; save(x); } + render(); + } }), + h('span', { class: 'small muted' }, '%')) : null); + } + function salesCard(s) { + const sales = [...s.sales].reverse(); + if (!sales.length) return null; + const today = dayKey(Date.now()); + const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today); + const sum = (k) => paidToday.reduce((n, x) => n + (x[k] || 0), 0); + const row = (x) => h('div', { class: 'row between pos-sale' + (x.status === 'paid' ? '' : ' faint') }, + h('div', { class: 'col', style: 'min-width:0;gap:1px' }, + h('span', {}, fmtAmount(x.billSat) + ' ' + unitLabel() + (x.tipSat ? ' + ' + fmtAmount(x.tipSat) + ' ' + t('posTipWord') : '')), + h('span', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, + new Date(x.paidAt || x.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (x.note ? ' · ' + x.note : '') + + (x.status === 'open' ? ' · ' + t('posAwaiting') : x.status === 'void' ? ' · ' + t('posVoid') : ''))), + h('span', { class: 'amount-pos', style: 'white-space:nowrap' }, (x.status === 'paid' ? '+' : '') + fmtAmount(x.billSat + x.tipSat))); + return h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', { style: 'margin:0' }, t('posSalesToday')), + h('div', { class: 'row between' }, h('span', { class: 'muted' }, t('posBills')), h('span', {}, fmtAmount(sum('billSat')) + ' ' + unitLabel())), + h('div', { class: 'row between' }, h('span', { class: 'muted' }, t('posTips')), h('span', {}, fmtAmount(sum('tipSat')) + ' ' + unitLabel())), + h('div', { class: 'row between', style: 'font-weight:600' }, h('span', {}, t('posTotal')), h('span', {}, fmtAmount(sum('billSat') + sum('tipSat')) + ' ' + unitLabel())), + h('div', { class: 'col', style: 'gap:6px;margin-top:4px' }, ...sales.slice(0, 30).map(row))); + } + // The customer's screen: the bill, and how much to add to it. + function tipScreen(p) { + const bill = billSat(); + const s = st(); + const choice = (mode, label, sub) => h('button', { + class: 'pos-tip' + (p.tipMode === mode ? ' on' : ''), type: 'button', + onClick: () => { p.tipMode = mode; render(); }, + }, h('span', { class: 'pos-tip-label' }, label), sub ? h('span', { class: 'small muted' }, sub) : null); + const tip = tipSat(); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, + h('div', { class: 'muted' }, t('posYourBill')), + money(bill), + h('div', { class: 'muted', style: 'margin-top:6px' }, t('posAddTip')), + h('div', { class: 'pos-tips' }, + ...s.tips.map((n) => choice(n, n + '%', '+' + fmtAmount(pct(bill, n)) + ' ' + unitLabel())), + choice('custom', t('posTipCustom'), null), + choice('none', t('posNoTip'), null)), + p.tipMode === 'custom' ? h('div', { class: 'input-group', style: 'width:100%' }, + h('input', { type: 'text', inputmode: 'decimal', placeholder: '0', value: p.tipCustom, autofocus: true, onInput: (e) => { p.tipCustom = e.target.value; render(); } }), + h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, unitLabel())) : null, + p.tipMode != null ? h('div', { class: 'small muted' }, t('posTotalLine', { n: fmtAmount(bill + tip) + ' ' + unitLabel() })) : null, + h('button', { class: 'btn-primary btn-block', disabled: p.tipMode == null || !!p.busy, + onClick: () => makeSale(bill, tip, p.tipMode === 'custom' || p.tipMode === 'none' ? 0 : p.tipMode) }, + p.busy ? h('span', { class: 'spinner sm' }) : t('posContinue'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => { p.step = 'amount'; render(); } }, t('back'))); + } + function invoiceScreen(p) { + const sale = p.sale; + const total = sale.billSat + sale.tipSat; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('div', { class: 'muted' }, t('posScanToPay')), + money(total, sale.fiat && sale.fiat.rate), + sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null, + h('div', { class: 'pos-qr', html: qrSvg(sale.invoice.toUpperCase()) }), + h('div', { class: 'row gap6', style: 'width:100%' }, + h('button', { class: 'grow', onClick: () => copy(sale.invoice) }, t('copy')), + h('button', { class: 'btn-ghost grow', onClick: newSale }, t('cancel'))), + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('posWaiting'))))); + } + function paidScreen(p) { + const sale = p.sale; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('div', { class: 'pos-check' }, '✓'), + h('h3', { style: 'margin:0' }, t('posPaid')), + money(sale.billSat + sale.tipSat, sale.fiat && sale.fiat.rate), + sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null, + h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))), + back()); + } + function posScreen() { + const p = ui.pos; + if (p.step === 'tip') return tipScreen(p); + if (p.step === 'invoice' && p.sale) return invoiceScreen(p); + if (p.step === 'paid' && p.sale) return paidScreen(p); + return amountScreen(p); + } + + return { + id: 'pos', + screenView() { + if (ui.screen !== 'wallet' || !ui.pos) return null; + return posScreen(); + }, + // Settings → Payments: the door, and the tip choices. + settingsCards() { + return [h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', {}, t('posTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('posDesc')), + h('button', { class: 'btn-primary', onClick: open }, t('posOpen')))]; + }, + // Under the payment address on Receive. + receiveExtras() { + return h('button', { class: 'btn-sm', onClick: open }, '\u{1F9FE} ' + t('posTitle')); + }, + openPos: open, + }; +} diff --git a/src/i18n.js b/src/i18n.js index 5ce4a18..721bf98 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -245,6 +245,36 @@ const STR = { feedSumFollowsPlus: 'Everyone you follow + {n} more', feedSumPeople: '{n} people', feedPacks: 'Follow packs', + posTitle: 'Point of sale', + posDesc: 'Charge a customer a set amount, with an optional tip prompt. Bills and tips are counted separately.', + posOpen: 'Open', + posEnterAmount: 'Enter an amount first', + posNeedsSpending: 'Point of sale takes Lightning into Spending — connect first', + posNoteHint: 'Note (optional)', + posCharge: 'Charge', + posAskTip: 'Ask the customer for a tip', + posTipChoices: 'Tip choices', + posYourBill: 'Your bill', + posAddTip: 'Add a tip?', + posTipCustom: 'Other', + posNoTip: 'No tip', + posTotalLine: 'Total {n}', + posContinue: 'Continue', + posScanToPay: 'Scan to pay', + posBreakdown: 'Bill {bill} + tip {tip} {u}', + posWaiting: 'Waiting for payment…', + posPaid: 'Paid', + posNewSale: 'New sale', + posInvoiceFailed: 'Could not create an invoice', + posInvoiceExpired: 'That invoice expired before it was paid', + posMemo: 'Bill {bill}, tip {tip}', + posSalesToday: 'Today', + posBills: 'Bills', + posTips: 'Tips', + posTotal: 'Total', + posTipWord: 'tip', + posVoid: 'cancelled', + posAwaiting: 'awaiting payment', linkHandlerTitle: 'Open links here', linkHandlerDesc: 'Make this app the one your browser opens for nostr links (what njump calls "Your default web app") and bitcoin: links.', linkHandlerNostr: 'nostr links', diff --git a/src/style.css b/src/style.css index e9bc882..2feab6e 100644 --- a/src/style.css +++ b/src/style.css @@ -590,6 +590,17 @@ hr.divider { margin: 16px 0; } post's full width so each is a comfortable target, quiet until one carries a count or you've used it. */ .note-acts { margin-top: 8px; justify-content: space-between; gap: 4px; max-width: 340px; } +/* point of sale */ +.pos-amount { font-size: 28px; font-weight: 600; text-align: right; } +.pos-big { font-size: 34px; font-weight: 700; line-height: 1.1; letter-spacing: -.5px; } +.pos-tips { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; } +.pos-tip { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 12px 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; } +.pos-tip.on { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); } +.pos-tip.on .muted { color: inherit; opacity: .8; } +.pos-tip-label { font-size: 20px; font-weight: 600; } +.pos-qr svg { width: min(260px, 70vw); height: auto; display: block; } +.pos-check { width: 64px; height: 64px; border-radius: 50%; background: var(--green, #2e7d32); color: #fff; display: flex; align-items: center; justify-content: center; font-size: 34px; } +.pos-sale.faint { opacity: .55; } /* the feeds row: pills that scroll sideways, the one on screen filled */ .feed-chips { gap: 6px; flex-wrap: nowrap; overflow-x: auto; margin: 0 -14px; padding: 2px 14px; scrollbar-width: none; -webkit-overflow-scrolling: touch; } .feed-chips::-webkit-scrollbar { display: none; } -- cgit v1.2.3