diff options
| author | Adam Soltys <[email protected]> | 2026-09-21 08:16:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 08:16:00 -0700 |
| commit | e2ca38a1088a0ec0595432726b2e708b14a811ea (patch) | |
| tree | d3153f4bd4066ccf78ff031dbcb26053d4697a80 | |
| parent | 40257dad7978797d5185e796136596ff10b6abec (diff) | |
| download | coinosv3-e2ca38a1088a0ec0595432726b2e708b14a811ea.tar.gz coinosv3-e2ca38a1088a0ec0595432726b2e708b14a811ea.zip | |
Point of sale: a bill, an optional tip prompt, one invoice, and a ledger that keeps tips apart.
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 <[email protected]>
| -rw-r--r-- | src/features/ark.js | 10 | ||||
| -rw-r--r-- | src/features/index.js | 5 | ||||
| -rw-r--r-- | src/features/names.js | 3 | ||||
| -rw-r--r-- | src/features/pos.js | 264 | ||||
| -rw-r--r-- | src/i18n.js | 30 | ||||
| -rw-r--r-- | src/style.css | 11 | ||||
| -rw-r--r-- | tools/pos-test.js | 96 |
7 files changed, 416 insertions, 3 deletions
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: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>', }))), - 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; } diff --git a/tools/pos-test.js b/tools/pos-test.js new file mode 100644 index 0000000..3dad594 --- /dev/null +++ b/tools/pos-test.js @@ -0,0 +1,96 @@ +// Point of sale: amount → tip prompt → one invoice for the total → paid, with +// the bill and the tip counted apart. The Ark side is faked: no relays, no +// server, no money. Run: bun tools/pos-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { posFeature } from './src/features/pos.js'; +${domHelper} +const state = {}; +const ui = { screen: 'wallet' }; +let watch = null, cancelled = [], invoices = 0; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = JSON.parse(JSON.stringify(v)); } }; +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), copy: (x) => (window.copied = x), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats', + brandHeader: () => null, + hook(name, ...a) { + if (name === 'arkReady') return true; + if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); } + if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; } + if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; } + if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); } + return null; + }, +}; +const feature = posFeature(ctx); +function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); } +window.test = { open: () => { feature.openPos(); }, settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, get sales() { return (state.pos || {}).sales || []; }, ui }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['pos-test-entry'], target: 'browser', + plugins: [{ name: 'pos-test', setup(build) { + build.onResolve({ filter: /^pos-test-entry$/ }, () => ({ path: 'entry', namespace: 'pos-test' })); + build.onLoad({ filter: /.*/, namespace: 'pos-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => (n.querySelector('.pos-tip-label') || n).textContent.trim() === x); if (!b) return false; b.click(); return true; }, label); +const type = async (sel, v) => { await page.evaluate((s) => { const i = document.querySelector(s); i.value = ''; }, sel); await page.type(sel, v); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#closed'); + await page.evaluate(() => test.open()); await pause(50); + check('the point of sale opens on the amount screen', /Point of sale/.test(await text()) && !!(await page.$('.pos-amount'))); + await click('Charge'); await pause(50); + check('charging nothing asks for an amount', /Enter an amount first/.test(await text())); + await type('.pos-amount', '1000'); await type('input[placeholder="Note (optional)"]', 'Table 4'); + await click('Charge'); await pause(50); + check('the tip prompt shows the bill and the tip choices', /Your bill 1,000 sats/.test(await text()) && /10%.*15%.*20%.*Other.*No tip/.test(await text())); + await click('15%'); await pause(50); + check('a percentage shows the total', /Total 1,150 sats/.test(await text())); + await click('Continue'); await pause(100); + check('one invoice is made for bill plus tip', await page.evaluate(() => test.watch === 'ln1') && /1,150 sats/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text()) && !!(await page.$('.pos-qr svg'))); + await click('Copy'); + check('copy hands over the invoice', await page.evaluate(() => window.copied === 'lnbc1150n1testinvoice1')); + await page.evaluate(() => test.settle(true)); await pause(50); + check('settlement shows Paid with the breakdown', /Paid/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text())); + await click('New sale'); await pause(50); + check('the ledger keeps the bill and the tip apart', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text()) && /Total 1,150 sats/.test(await text()) && /Table 4/.test(await text())); + + console.log('\n[a custom tip, then a cancelled sale]'); + await type('.pos-amount', '2000'); await click('Charge'); await pause(50); + await click('Other'); await pause(50); + await page.evaluate(() => { const i = document.querySelector('input[placeholder="0"]:not(.pos-amount)'); i.value = '50'; i.dispatchEvent(new Event('input')); }); await pause(50); + check('a custom tip adds what was typed', /Total 2,050 sats/.test(await text())); + await click('Continue'); await pause(100); + await click('Cancel'); await pause(50); + check('cancelling voids the sale and the invoice', await page.evaluate(() => test.cancelled.includes('ln2') && test.sales.find((s) => s.billSat === 2000).status === 'void') && /cancelled/.test(await text())); + check('a cancelled sale does not count', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text())); + + console.log('\n[no tip prompt]'); + await page.click('input[type=checkbox]'); await pause(50); + await type('.pos-amount', '300'); await click('Charge'); await pause(100); + check('with the prompt off, Charge goes straight to the invoice', /Scan to pay/.test(await text()) && /300 sats/.test(await text()) && await page.evaluate(() => test.watch === 'ln3')); + await page.evaluate(() => test.settle(false)); await pause(50); + check('an expired invoice returns to the amount screen and says so', /That invoice expired/.test(await text())); + check('no browser errors', errors.length === 0); +} finally { await browser.close(); server.stop(true); } +console.log('\n✅ point of sale'); |
