aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-21 10:56:47 -0700
committerAdam Soltys <[email protected]>2026-09-21 10:56:47 -0700
commitffec5e1a52596a3be867558c41b43ac9b3892c0c (patch)
treef50097e6e0e9ba7841bee22ee7e03fd636906f89 /src/features
parent683184f65eb71bdfd12550e7293f0c721100dd9b (diff)
downloadcoinosv3-ffec5e1a52596a3be867558c41b43ac9b3892c0c.tar.gz
coinosv3-ffec5e1a52596a3be867558c41b43ac9b3892c0c.zip
Point of sale: /pos, a per-device till mode, an on-screen pad — and off the Receive tab.
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js2
-rw-r--r--src/features/names.js3
-rw-r--r--src/features/pos.js52
3 files changed, 42 insertions, 15 deletions
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,
};