aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js11
-rw-r--r--src/features/messages.js2
-rw-r--r--src/features/names.js3
-rw-r--r--src/features/pos.js52
-rw-r--r--src/i18n.js3
-rw-r--r--src/style.css4
-rw-r--r--tools/pos-test.js20
7 files changed, 74 insertions, 21 deletions
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: '<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');