diff options
| -rw-r--r-- | src/app.js | 11 | ||||
| -rw-r--r-- | src/features/messages.js | 2 | ||||
| -rw-r--r-- | src/features/names.js | 3 | ||||
| -rw-r--r-- | src/features/pos.js | 52 | ||||
| -rw-r--r-- | src/i18n.js | 3 | ||||
| -rw-r--r-- | src/style.css | 4 | ||||
| -rw-r--r-- | tools/pos-test.js | 20 |
7 files changed, 74 insertions, 21 deletions
@@ -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: '<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(), - (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; } diff --git a/tools/pos-test.js b/tools/pos-test.js index 3dad594..a50d9de 100644 --- a/tools/pos-test.js +++ b/tools/pos-test.js @@ -29,7 +29,7 @@ const ctx = { }; 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 }; +window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); }, 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({ @@ -42,17 +42,19 @@ const bundle = await Bun.build({ 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 html = `<!doctype html><meta charset="utf-8"><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 check = (name, value, detail = '') => { assert(value, name + (detail ? ' — ' + detail : '')); 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); }; +// the harness repaints by replacing nodes (the app morphs in place), so a +// field is filled in one go rather than keystroke by keystroke +const type = (sel, v) => page.evaluate(([s, val]) => { const i = document.querySelector(s); i.value = val; i.dispatchEvent(new Event('input', { bubbles: true })); }, [sel, v]); try { await page.setViewport({ width: 390, height: 844 }); await page.goto(server.url.href); @@ -61,7 +63,11 @@ try { 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'); + for (const k of ['1', '0', '0', '0']) await click(k); + check('the on-screen pad fills the amount field', await page.$eval('.pos-amount', (i) => i.value) === '1000'); + const hitDel = await click('\u232b'); const afterDel = await page.$eval('.pos-amount', (i) => i.value); await click('0'); + check('backspace edits the same field', await page.$eval('.pos-amount', (i) => i.value) === '1000', JSON.stringify({ hitDel, afterDel })); + 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); @@ -78,7 +84,7 @@ try { 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); + await click('5'); await click('0'); 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); @@ -91,6 +97,8 @@ try { 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())); + await page.evaluate(() => { test.ui.pos = null; test.ui.posAtBoot = true; test.init(); }); await pause(50); + check('the /pos link opens the till once a wallet is up', !!(await page.$('.pos-amount'))); check('no browser errors', errors.length === 0); } finally { await browser.close(); server.stop(true); } console.log('\n✅ point of sale'); |
