From ffec5e1a52596a3be867558c41b43ac9b3892c0c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 21 Sep 2026 10:56:47 -0700 Subject: Point of sale: /pos, a per-device till mode, an on-screen pad — and off the Receive tab. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.coinos.io/pos opens the point of sale once a wallet is up (claimed at boot before any feature reads the path), so the till can be bookmarked or put on a home screen. Settings → Payments gains "Start in point of sale on this device" (localStorage, not synced: the till, not every phone the owner carries). The amount and the custom tip get a 3×4 pad that edits the same field a keyboard or a paste would. The button under the payment address on Receive is gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp --- src/app.js | 11 ++++++++++ src/features/messages.js | 2 +- src/features/names.js | 3 +-- src/features/pos.js | 52 +++++++++++++++++++++++++++++++++++++----------- src/i18n.js | 3 +++ src/style.css | 4 ++++ 6 files changed, 60 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/app.js b/src/app.js index 58bc242..08a6901 100644 --- a/src/app.js +++ b/src/app.js @@ -5487,6 +5487,17 @@ const wakeDone = () => { try { if (WAKE_PAYLOAD != null && window.CoinosHost && window.CoinosHost.done) window.CoinosHost.done(); } catch {} }; +// v3.coinos.io/pos: the point of sale, straight away — a bookmark or a +// home-screen icon for the till. Consumed here (before any feature reads +// the path) and opened once a wallet is up; the pos feature owns the rest. +const BOOT_POS = (() => { + try { + if (!/^\/pos\/?$/i.test(location.pathname)) return false; + history.replaceState(null, '', '/' + (location.search || '')); + return true; + } catch { return false; } +})(); +if (BOOT_POS) ui.posAtBoot = true; const INTENT_URI = (() => { try { const u = new URLSearchParams(location.search).get('u'); diff --git a/src/features/messages.js b/src/features/messages.js index 075f33f..b4f24a5 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -1768,7 +1768,7 @@ export function messagesFeature(ctx) { // reserved app routes are never usernames — /chat is the public // community page (app.js routes it), and eating it here rewrote the URL // to / before that route ever saw it; a note reference is its own link - if (m && (['chat'].includes(m[1]) || /^(note|nevent|naddr)1/i.test(m[1]))) return null; + if (m && (['chat', 'pos'].includes(m[1]) || /^(note|nevent|naddr)1/i.test(m[1]))) return null; return m ? m[1] : null; })(); if (urlProfile) { try { history.replaceState(null, '', '/'); } catch {} } diff --git a/src/features/names.js b/src/features/names.js index 0024847..f05ac09 100644 --- a/src/features/names.js +++ b/src/features/names.js @@ -588,8 +588,7 @@ export function namesFeature(ctx) { onClick: () => copy(addr), html: '', }))), - moreSection(), - (ctx.hook && ctx.hook('receiveExtras')) || null); + moreSection()); } // ---- BOLT 12 offers with a memo ---------------------------------------- diff --git a/src/features/pos.js b/src/features/pos.js index 9780aa1..42e4590 100644 --- a/src/features/pos.js +++ b/src/features/pos.js @@ -28,6 +28,11 @@ export function posFeature(ctx) { }; const save = (s) => { try { wallet.saveFeatureState('pos', s); } catch {} }; const SALES_MAX = 500; + // Point-of-sale mode is per device (the till, not every phone the owner + // carries): with it on, the app opens straight onto the point of sale. + const POS_MODE = 'btc-wallet-pos-mode'; + const posMode = () => { try { return localStorage.getItem(POS_MODE) === '1'; } catch { return false; } }; + const setPosMode = (on) => { try { if (on) localStorage.setItem(POS_MODE, '1'); else localStorage.removeItem(POS_MODE); } catch {} }; const fiatLine = (sats, rate = rateNow(), code = getCurrency()) => (rate ? fmtFiat(sats, rate, code) : ''); const pct = (sats, p) => Math.round(sats * p / 100); @@ -39,6 +44,21 @@ export function posFeature(ctx) { ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }; render(); } + // Digits on screen, into the same field a keyboard or a paste would fill. + function numpad(get, set) { + const press = (k) => { + let v = String(get() || ''); + if (k === 'del') v = v.slice(0, -1); + else if (k === '.') { if (!v.includes('.')) v = (v || '0') + '.'; } + else if (v === '0') v = k; + else v += k; + set(v); render(); + }; + const key = (k, label) => h('button', { class: 'pos-key' + (k === 'del' ? ' del' : ''), type: 'button', 'aria-label': k === 'del' ? t('posBackspace') : label, onClick: () => press(k) }, label); + return h('div', { class: 'pos-pad' }, + ...['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((k) => key(k, k)), + key('.', '.'), key('0', '0'), key('del', '\u232b')); + } function close() { const p = ui.pos; if (p && p.sale && p.sale.status === 'open') voidSale(p.sale); @@ -130,12 +150,13 @@ export function posFeature(ctx) { 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; }, + type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount, + onInput: (e) => { p.amount = e.target.value; render(); }, 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, + numpad(() => p.amount, (v) => { p.amount = v; }), 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')), @@ -200,9 +221,11 @@ export function posFeature(ctx) { ...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 === 'custom' ? h('div', { class: 'col', style: 'width:100%;gap:8px' }, + h('div', { class: 'input-group' }, + h('input', { type: 'text', inputmode: 'decimal', placeholder: '0', value: p.tipCustom, onInput: (e) => { p.tipCustom = e.target.value; render(); } }), + h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, unitLabel())), + numpad(() => p.tipCustom, (v) => { p.tipCustom = v; })) : null, p.tipMode != null ? h('div', { class: 'pos-total' }, t('posTotalLine', { n: fmtAmount(bill + tip) + ' ' + unitLabel() })) : null, p.error ? h('div', { class: 'notice error small', style: 'width:100%' }, p.error) : null, h('button', { class: 'btn-primary btn-block', disabled: p.tipMode == null || !!p.busy, @@ -248,20 +271,25 @@ export function posFeature(ctx) { return { id: 'pos', + // A wallet just opened: the /pos link or point-of-sale mode lands here. + init() { + if (ui.posAtBoot || posMode()) { ui.posAtBoot = false; open(); } + }, screenView() { if (ui.screen !== 'wallet' || !ui.pos) return null; return posScreen(); }, - // Settings → Payments: the door, and the tip choices. + // Settings → Payments: the door, the mode, and where to bookmark. settingsCards() { - return [h('div', { class: 'card col', style: 'gap:8px' }, + const url = (typeof location !== 'undefined' ? location.origin : 'https://v3.coinos.io') + '/pos'; + return [h('div', { class: 'card col', style: 'gap:10px' }, 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')); + h('button', { class: 'btn-primary', onClick: open }, t('posOpen')), + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: posMode(), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (e) => { setPosMode(e.target.checked); render(); } }), + h('span', {}, t('posModeToggle'))), + h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))]; }, openPos: open, }; diff --git a/src/i18n.js b/src/i18n.js index 2fa2dc3..2283fd6 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -276,6 +276,9 @@ const STR = { posVoid: 'cancelled', posAwaiting: 'awaiting payment', posMakingInvoice: 'Creating the invoice…', + posBackspace: 'Delete', + posModeToggle: 'Start in point of sale on this device', + posLinkHint: 'The till can also be bookmarked or added to the home screen: {url}', 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 d0d5103..12895e7 100644 --- a/src/style.css +++ b/src/style.css @@ -602,6 +602,10 @@ hr.divider { margin: 16px 0; } .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; } +.pos-pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; width: 100%; } +.pos-key { padding: 14px 0; font-size: 22px; font-weight: 600; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); color: var(--ink); cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +.pos-key:active { background: var(--accent); color: var(--accent-fg); } +.pos-key.del { font-weight: 400; } /* 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