aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/app.js6
-rw-r--r--src/features/names.js2
-rw-r--r--src/features/pos.js30
-rw-r--r--src/i18n.js5
-rw-r--r--tools/pos-test.js20
-rw-r--r--tools/signup-quick-test.js9
6 files changed, 57 insertions, 15 deletions
diff --git a/src/app.js b/src/app.js
index 0cf2447..3144e9b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5744,7 +5744,8 @@ function tabContent() {
if (ui.tab === 'send' && sendFocused) return pane;
return h('div', { class: 'col', style: 'gap:16px' },
pane,
- h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')),
+ // no heading over nothing: it comes with the first payment
+ hist ? h('div', { class: 'small faint', style: 'text-transform:uppercase;letter-spacing:.06em;margin-top:4px' }, t('tabHistory')) : null,
hist);
}
@@ -5832,7 +5833,8 @@ function receiveTab() {
{ class: 'card col', style: 'align-items:center;gap:14px' },
h('div', { html: qrSvg(ui.arkRecvAddr) }),
h('div', { class: 'addr-box break', style: 'width:100%' }, ui.arkRecvAddr),
- copyBtn(ui.arkRecvAddr, t('copyAddress'))
+ copyBtn(ui.arkRecvAddr, t('copyAddress')),
+ featureHook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => featureHook('openAmountRequest') }, t('recvAmount')) : null
);
}
}
diff --git a/src/features/names.js b/src/features/names.js
index 3f23a0c..81b64e6 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -683,6 +683,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>',
}))),
+ // ask for a sum: the point of sale's amount pad and invoice, simplified
+ hook('posAvailable') ? h('button', { class: 'btn-block', id: 'recv-amount', onClick: () => hook('openAmountRequest') }, t('recvAmount')) : null,
moreSection());
}
diff --git a/src/features/pos.js b/src/features/pos.js
index 3727862..eb4b0e9 100644
--- a/src/features/pos.js
+++ b/src/features/pos.js
@@ -44,8 +44,11 @@ export function posFeature(ctx) {
// ---- 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: '' };
+ // `request`: Receive → Amount (Adam, 2026-11-15) — the same amount pad
+ // and invoice for asking someone for a sum, without the till around it:
+ // no tip prompt, no tip settings or day's sales, and Back is Receive.
+ function open(opts = {}) {
+ ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: !!opts.request };
render();
}
// Digits on screen, into the same field a keyboard or a paste would fill.
@@ -90,7 +93,7 @@ export function posFeature(ctx) {
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; }
+ if (st().askTip && !p.request) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; }
makeSale(bill, 0, 0);
}
function tipSat() {
@@ -107,6 +110,7 @@ export function posFeature(ctx) {
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,
+ ...(p.request ? { request: true } : {}),
};
try {
const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' });
@@ -145,7 +149,7 @@ export function posFeature(ctx) {
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: '' });
+ Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '', request: p.request });
render();
}
@@ -194,7 +198,7 @@ export function posFeature(ctx) {
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('h3', { style: 'margin:0' }, t(p.request ? 'recvAmountTitle' : 'posTitle')),
h('div', { class: 'input-group' },
h('input', {
type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount,
@@ -204,12 +208,12 @@ export function posFeature(ctx) {
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; } }),
+ h('input', { type: 'text', placeholder: t(p.request ? 'recvAmountNote' : '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')),
+ h('button', { class: 'btn-primary btn-block', id: 'pos-charge', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t(p.request ? 'recvAmountGo' : 'posCharge')),
p.busy ? h('div', { class: 'small muted', style: 'text-align:center' }, t('posMakingInvoice')) : null),
- tipSettingsCard(s),
- salesCard(s),
+ p.request ? null : tipSettingsCard(s),
+ p.request ? null : salesCard(s),
back());
}
function tipSettingsCard(s) {
@@ -231,7 +235,7 @@ export function posFeature(ctx) {
}
function salesCard(s) {
adoptPaid(s);
- const sales = [...s.sales].reverse();
+ const sales = s.sales.filter((x) => !x.request).reverse(); // Receive → Amount isn't a sale
if (!sales.length) return null;
const today = dayKey(Date.now());
const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today);
@@ -310,8 +314,8 @@ export function posFeature(ctx) {
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());
+ p.request ? null : h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))),
+ p.request ? h('button', { class: 'btn-primary btn-block', onClick: close }, t('recvAmountDone')) : back());
}
function posScreen() {
const p = ui.pos;
@@ -346,6 +350,8 @@ export function posFeature(ctx) {
h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))];
},
openPos: open,
+ openAmountRequest: () => open({ request: true }),
+ posAvailable: () => !!hook('arkReady'),
// The history detail of a till payment says what was the bill and what the tip.
arkMoveDetailExtra(m, row) {
const sale = saleOfMovement(m);
diff --git a/src/i18n.js b/src/i18n.js
index 121c806..97bb066 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1677,6 +1677,11 @@ const STR = {
settingsNotifDesc: 'What buzzes this device',
settingsAdvancedDesc: 'Network, rarities, rescues',
aboutTitle: 'About',
+ recvAmount: 'Amount',
+ recvAmountTitle: 'Request an amount',
+ recvAmountNote: 'What it’s for (optional)',
+ recvAmountGo: 'Create invoice',
+ recvAmountDone: 'Done',
settingsAboutDesc: 'How it works, support, app, theme',
settingsPosDesc: 'Till mode, tips and the /pos link',
unlockForLabel: 'Stay unlocked',
diff --git a/tools/pos-test.js b/tools/pos-test.js
index 55349cb..2b6a0b7 100644
--- a/tools/pos-test.js
+++ b/tools/pos-test.js
@@ -30,7 +30,7 @@ const ctx = {
};
const feature = posFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); }
-window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); },
+window.test = { open: () => { feature.openPos(); }, request: () => { feature.openAmountRequest(); }, init: () => { feature.init(); render(); },
cards: () => { document.querySelector('#app').replaceChildren(...feature.posSettingsCards()); }, 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();
`;
@@ -128,6 +128,24 @@ try {
check("the old device-wide flag is dropped on start (it can't say whose it was)", await page.evaluate(() => (test.ui.pos = null, true)) && (await boot('owner')) && await page.evaluate(() => localStorage.getItem('btc-wallet-pos-mode') === null));
check('another wallet on the same device does not start in point of sale', !(await boot('someone-else')));
check('the owner still does, after anyone else has been in', await boot('owner'));
+
+ console.log('\n[Receive → Amount]');
+ await page.evaluate(() => { localStorage.removeItem('btc-wallet-pos-mode:owner'); test.ui.pos = null; test.request(); }); await pause(50);
+ let rq = await text();
+ check('opens as Request an amount, without the till around it', /Request an amount/.test(rq) && !/ask for a tip|Tips today|Sales today/i.test(rq) && !(await page.$('.pos-sale')), rq.slice(0, 200));
+ const before = await page.evaluate(() => test.sales.length);
+ await type('.pos-amount', '2100');
+ await page.click('#pos-charge'); await pause(100);
+ rq = await text();
+ check('straight to the invoice, no tip prompt', /Scan to pay/i.test(rq) && /2,100/.test(rq) && !!(await page.$('.pos-qr')), rq.slice(0, 200));
+ const req = await page.evaluate(() => test.sales[test.sales.length - 1]);
+ check('the request is for the amount, no tip, and marked', req.billSat === 2100 && req.tipSat === 0 && req.request === true, JSON.stringify(req));
+ await page.evaluate(() => test.settle(true)); await pause(100);
+ check('paid, with Done back to Receive', /Done/.test(await text()) && !/New sale/.test(await text()));
+ await click('Done'); await pause(50);
+ check('Done closes it', !!(await page.$('#closed')));
+ await page.evaluate(() => { test.ui.pos = null; test.open(); }); await pause(50);
+ check("the till's sales list leaves requests out", !(await text()).includes('2,100'), String(before));
check('no browser errors', errors.length === 0);
} finally { await browser.close(); server.stop(true); }
console.log('\n✅ point of sale');
diff --git a/tools/signup-quick-test.js b/tools/signup-quick-test.js
index d648a70..d184457 100644
--- a/tools/signup-quick-test.js
+++ b/tools/signup-quick-test.js
@@ -83,6 +83,15 @@ try {
let s = await shape();
check('in, on Spending alone (no Savings face, no name on it)', s.faces === 1 && !s.carousel && !s.label, JSON.stringify({ faces: s.faces, carousel: s.carousel, label: s.label }));
check('a quiet line asks for a way back in', /add a way back into your account/i.test(s.body));
+ for (let i = 0; i < 60 && !(await page.$('#recv-amount')); i++) await sleep(250);
+ check('Receive has an Amount button', !!(await page.$('#recv-amount')));
+ check('no History heading on an empty wallet', !/\bHISTORY\b/.test(await page.evaluate(() => document.body.innerText)));
+ if (process.env.SHOT3) await page.screenshot({ path: process.env.SHOT3 });
+ await page.click('#recv-amount'); await sleep(500);
+ check('Amount opens the amount pad', !!(await page.$('.pos-amount')) && /request an amount/i.test(await text()));
+ if (process.env.SHOT4) await page.screenshot({ path: process.env.SHOT4 });
+ await click('back'); await sleep(500);
+ check('Back returns to Receive', !(await page.$('.pos-amount')) && !!(await page.$('#recv-amount')));
const acct = await page.evaluate(() => JSON.parse(sessionStorage.getItem('btc-wallet-accounts') || '{}'));
const a0 = (acct.accounts || [])[0] || {};
check('the account: spending-only, phrase unseen, saved to the device', a0.kind === 'spending' && a0.savingsLater && a0.seedSeen === false && a0.persisted, JSON.stringify({ kind: a0.kind, later: a0.savingsLater, seen: a0.seedSeen, persisted: a0.persisted }));