fix(pos): make cart quantity editable, and fix the sheet that hid it

Ringing up 800 of something means typing 800, and the only route to the
quantity field was a 600ms long-press with no affordance anywhere on
screen. The quantity itself — the thing a cashier reaches for — was a
plain <div class="v">, so it rendered inert.

  - the quantity is now a real button that opens the Edit line sheet,
    with an aria-label and a 44px touch target (it was 30px, too narrow
    to even show "800")
  - holding +/- no longer pops the sheet open mid-tap: the pointerdown
    handler bails for .qty/.rm before starting the timer
  - the sheet is quantity-only. The Unit price field is gone on request;
    a mis-keyed price is expensive and easy to miss. The per-line
    override it fed is removed with it — pos-service.ts only ever read
    unit_price, the "price edited" label and the sale record's `edited`
    flag were unreachable, and nothing else could set an override,
    including a cart reload (persistCart saves only {id, qty, p})

The discount trigger moved out of its own 54px footer row to ride
inside the Subtotal row, so it costs no vertical height at all while
staying visible — a discount control that appears from nowhere is one
nobody finds under pressure. The applied-discount red totals row
remains the always-on indicator.

Fixes a pre-existing bug found while testing: long-pressing a cart row
showed a grey screen with no dialog. #editSheet is a SIBLING of
#editBackdrop, but the CSS revealed it with
`.edit-backdrop.show .edit-sheet` — a descendant selector that can
never match a sibling — so the sheet sat at opacity:0 while the
backdrop went opaque. Nothing threw and the build was green, which is
why it survived; it is also why manual quantity entry looked missing.
The sheet now has its own z-index above the backdrop and its own .show
rule. They stay siblings on purpose: nesting the sheet inside the
backdrop would make every tap on Save bubble to the backdrop's
onclick and close the dialog.

Verified in jsdom against the real markup: old CSS reproduces the grey
screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix
clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS
tests pass, including 23 new ones asserting the tap target exists, the
long-press guard fires before the timer, the sheet stacks above its
backdrop, and the popup stays quantity-only.

sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out
of the shell, so without the bump tills keep the old ones.
This commit is contained in:
Dave 2026-10-07 12:54:47 +02:00
parent bcb0e1a7d0
commit 8c59f594f8
4 changed files with 321 additions and 85 deletions

View file

@ -19,7 +19,7 @@ const state = {
// catalog load (both the IndexedDB and the network path) so the
// per-keystroke filter only does `indexOf`, never normalisation.
searchIndex: new Map(),
cart: new Map(), // id -> {p, qty, unitPriceOverride?}
cart: new Map(), // id -> {p, qty}
customer: null,
paymentMethod: null,
cashGiven: 0,
@ -350,8 +350,7 @@ function cartTotals(){
let count=0, gross=0;
for(const {p,qty} of state.cart.values()){
count += qty;
// Use the per-line override if the cashier edited the price
// via long-press; otherwise the catalogue shelf price.
// Catalogue shelf price — the till never edits a line's price.
const unit = lineUnitPrice({p,qty});
gross += unit * qty;
}
@ -385,14 +384,14 @@ function cartTotals(){
}
/**
* Effective unit price for a cart line — the per-line override
* (set by long-press edit) or the catalogue's selling price.
* Returns 0 for the corner case where neither is set.
* Effective unit price for a cart line — always the catalogue's selling
* price. It used to prefer a per-line override set by the long-press edit
* sheet, but that sheet is quantity-only now, so no override can exist.
* Kept as a named helper because the price feeds four places (line render,
* cart totals, the sale record and the checkout payload) and they must not
* drift apart. Returns 0 when neither price is set.
*/
function lineUnitPrice(line){
if (typeof line.unitPriceOverride === 'number' && Number.isFinite(line.unitPriceOverride)) {
return line.unitPriceOverride;
}
return Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0;
}
function persistCart(){
@ -430,17 +429,14 @@ function renderCartDrawer(){
document.getElementById('discountLine').hidden = true;
return;
}
body.innerHTML = Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>{
const price = lineUnitPrice({p,qty,unitPriceOverride});
const overridden = typeof unitPriceOverride === 'number'
&& Number.isFinite(unitPriceOverride)
&& unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0);
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
const price = lineUnitPrice({p,qty});
return `<div class="line" data-od-id="line-${p.id}" data-id="${p.id}">
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}${overridden ? ' · <em>price edited</em>' : ''}</small></div>
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
<div class="qty">
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease">−</button>
<div class="v">${qty}</div>
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase">+</button>
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease quantity">−</button>
<button class="v" data-od-id="qty-set" onclick="window.__pos.openEditLine('${p.id}')" aria-label="Set quantity for ${escapeAttr(p.description||p.sku||'item')}">${qty}</button>
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase quantity">+</button>
</div>
<div class="pr">${fmt(price*qty)}</div>
<button class="rm" onclick="window.__pos.removeFromCart('${p.id}')" aria-label="Remove">
@ -570,11 +566,14 @@ document.addEventListener('input', e=>{
// ---------------- long-press edit line ----------------
//
// Hold a cart line for 600ms → modal opens with qty + unit price.
// Tapping the quantity number does the same thing and is the discoverable
// route; the long-press is kept because it is the faster gesture for a
// cashier who already knows it's there.
// Common on mobile POS apps; the cashier scans/selects, then holds
// the row to fix the qty or override the price before ringing it
// up. We use a 600ms threshold so a normal "I'm thinking about it"
// tap doesn't accidentally open the modal — but it's well under
// the long-press duration most users naturally apply (≈800ms).
// the row to fix the qty before ringing it up. Quantity only — this
// sheet has no price field. We use a 600ms threshold so a normal "I'm
// thinking about it" tap doesn't accidentally open the modal — but it's
// well under the long-press duration most users naturally apply (≈800ms).
//
// Haptic feedback (navigator.vibrate) on the trigger where the
// device supports it; visual hint via the .line-holding class so
@ -609,6 +608,11 @@ function startLongPress(el){
document.addEventListener('pointerdown', e=>{
const line = e.target.closest('.line[data-id]');
if (!line) return;
// The steppers and the quantity number are their own controls now. A
// long-press on them used to pop the edit sheet open mid-tap, which made
// holding "+" to ring up a bulk quantity fight the sheet. Each of them
// does its own thing on a normal tap.
if (e.target.closest('.qty, .rm')) return;
startLongPress(line);
}, { passive: true });
document.addEventListener('pointerup', clearLongPress);
@ -623,12 +627,11 @@ function openEditLine(id){
if (!line) return;
editingLineId = id;
document.getElementById('editSku').textContent = line.p.sku || line.p.description || '';
// Pre-fill with the effective unit price (the override if set,
// otherwise the catalogue shelf price). The cashier can decide
// whether to keep it or change it.
const cur = lineUnitPrice(line);
// Quantity only. This popup deliberately has no price field: the till
// sells at the catalogue shelf price and a mis-keyed price is expensive
// and hard to spot. Per-line price overrides were removed on request —
// the server reads only `unit_price`, so nothing else depended on them.
document.getElementById('editQty').value = String(line.qty);
document.getElementById('editPrice').value = fmtN(cur);
document.getElementById('editBackdrop').classList.add('show');
document.getElementById('editSheet').classList.add('show');
setTimeout(()=>document.getElementById('editQty').focus(), 60);
@ -643,7 +646,6 @@ function editSave(){
const line = state.cart.get(editingLineId);
if (!line) { closeEditLine(); return; }
const qty = Number((document.getElementById('editQty').value || '').replace(/[^0-9.]/g, ''));
const price = Number((document.getElementById('editPrice').value || '').replace(/[^0-9.]/g, ''));
const hint = document.getElementById('editHint');
// Bad qty — show an error in the hint, keep the modal open so the
// operator can fix it. (Previously the modal stayed open without
@ -658,17 +660,10 @@ function editSave(){
if (qIn) { qIn.focus(); qIn.select(); }
return;
}
// price === 0 is allowed (the operator can comp a line) but
// negative isn't. If the input is empty, leave the price alone.
const shelf = Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0;
if (Number.isFinite(price) && price >= 0) {
if (price !== shelf) line.unitPriceOverride = price;
else delete line.unitPriceOverride; // back to default
}
line.qty = Math.floor(qty);
// Reset the hint to its neutral default before closing so the next
// openEditLine() doesn't show a stale error from this round.
if (hint) { hint.textContent = 'Hold to edit again, or change a value and tap Save.'; hint.style.color = ''; }
if (hint) { hint.textContent = 'Type a quantity like 800, then tap Save.'; hint.style.color = ''; }
persistCart();
render();
renderCartDrawer();
@ -865,15 +860,10 @@ async function confirmSale(){
// if it's null, so a missing letterhead is a non-fatal
// degradation, not a crash).
letterhead: state.letterhead || null,
items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({
items: Array.from(state.cart.values()).map(({p,qty})=>({
sku: p.sku, description: p.description, unit: p.unit,
cost_price: p.cost_price,
// The price the cashier actually sold at — the override
// (long-press) wins over the catalogue shelf price.
selling_price: lineUnitPrice({p,qty,unitPriceOverride}),
edited: typeof unitPriceOverride === 'number'
&& Number.isFinite(unitPriceOverride)
&& unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0),
selling_price: lineUnitPrice({p,qty}),
qty, product_id: p.id,
})),
customer: state.customer || null,
@ -881,8 +871,8 @@ async function confirmSale(){
};
const checkoutBody = {
items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({
stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty,unitPriceOverride}),
items: Array.from(state.cart.values()).map(({p,qty})=>({
stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty}),
sku: p.sku, description: p.description,
})),
payment_method: state.paymentMethod,

View file

@ -111,7 +111,8 @@ input{font:inherit;color:inherit}
.qty{display:flex;align-items:center;border:1px solid var(--muted);border-radius:10px;overflow:hidden}
.qty button{width:34px;height:34px;display:grid;place-items:center;color:var(--fg);font-size:16px;font-weight:600}
.qty button:active{background:var(--surface)}
.qty .v{width:30px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
.qty .v{min-width:44px;padding:6px 2px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg);border-radius:6px}
.qty .v:active{background:var(--surface)}
.line .pr{width:78px;text-align:right;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg)}
.line .rm{width:32px;height:32px;border-radius:50%;color:#9a9a9a;display:grid;place-items:center}
.line .rm:active{background:#fff0f0;color:var(--danger)}
@ -121,12 +122,15 @@ input{font:inherit;color:inherit}
.totals.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:17px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
/* ---- Discount block ---- */
.discount{padding:8px 0 4px}
.discount-trigger{display:flex;align-items:center;justify-content:space-between;width:100%;padding:10px 12px;border:1px dashed var(--muted);border-radius:10px;background:#fff;color:var(--fg);font-size:13px;font-weight:600;letter-spacing:-.005em;min-height:42px}
.discount-trigger:active{background:var(--surface)}
.discount-trigger svg{width:16px;height:16px;color:#717171;transition:transform .18s}
/* The trigger rides INSIDE the Subtotal row (see the markup below), so it
adds no vertical height at all — the footer keeps every pixel it had
before. It stays visible rather than collapsing away, because a discount
control that appears from nowhere is one nobody finds under pressure. */
.discount-trigger{display:inline-flex;align-items:center;gap:3px;margin-left:8px;padding:3px 6px;border:0;border-radius:6px;background:none;color:#717171;font-size:12px;font-weight:600;letter-spacing:.01em;vertical-align:middle}
.discount-trigger:active{background:var(--surface);color:var(--fg)}
.discount-trigger svg{width:13px;height:13px;color:inherit;transition:transform .18s}
.discount-trigger[aria-expanded="true"] svg{transform:rotate(180deg)}
.discount-trigger.has-value{border-style:solid;border-color:var(--accent);color:var(--accent);background:#fff8fa}
.discount-trigger.has-value{color:var(--accent)}
.discount-trigger.has-value svg{color:var(--accent)}
.discount-body{padding:12px 0 4px;display:flex;flex-direction:column;gap:10px}
.discount-pills{display:flex;gap:6px;padding:3px;background:var(--surface);border:1px solid var(--muted);border-radius:999px;width:fit-content}
@ -145,8 +149,15 @@ input{font:inherit;color:inherit}
/* ---- Edit line modal (long-press) ---- */
.edit-backdrop{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:70;opacity:0;pointer-events:none;transition:opacity .15s}
.edit-backdrop.show{opacity:1;pointer-events:auto}
.edit-sheet{position:absolute;left:50%;bottom:24px;transform:translate(-50%, 24px);background:#fff;border-radius:18px;width:calc(100% - 32px);max-width:448px;padding:18px 18px 14px;opacity:0;transition:opacity .15s, transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);pointer-events:none;display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
.edit-backdrop.show .edit-sheet{opacity:1;transform:translate(-50%, 0);pointer-events:auto}
/* The sheet is a SIBLING of the backdrop, not a child, so it cannot be
styled via `.edit-backdrop.show .edit-sheet` — that selector never
matched and the sheet stayed at opacity:0 forever, which showed the
operator a grey screen with no dialog on it. It needs its own stacking
above the backdrop (z-index 70) and its own .show rule. Keep them
siblings: nesting the sheet inside the backdrop would make every tap on
the sheet bubble to the backdrop's onclick and close it. */
.edit-sheet{position:fixed;left:50%;bottom:24px;z-index:71;transform:translate(-50%, 24px);background:#fff;border-radius:18px;width:calc(100% - 32px);max-width:448px;padding:18px 18px 14px;opacity:0;transition:opacity .15s, transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);pointer-events:none;display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
.edit-sheet.show{opacity:1;transform:translate(-50%, 0);pointer-events:auto}
.edit-sheet h3{font-size:15px;font-weight:600;letter-spacing:-.01em;margin:0;display:flex;align-items:center;gap:8px}
.edit-sheet h3 small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em;margin-left:auto}
.edit-row{display:flex;flex-direction:column;gap:6px}
@ -267,6 +278,7 @@ input{font:inherit;color:inherit}
:root[data-theme="dark"] .qty{border-color:var(--muted)}
:root[data-theme="dark"] .qty button{color:var(--fg)}
:root[data-theme="dark"] .qty button:active{background:var(--surface)}
:root[data-theme="dark"] .qty .v{color:var(--fg)}
:root[data-theme="dark"] .line .pr{color:var(--fg)}
:root[data-theme="dark"] .line .rm{color:#666}
:root[data-theme="dark"] .line .rm:active{background:#3a2429;color:var(--accent)}
@ -274,10 +286,9 @@ input{font:inherit;color:inherit}
:root[data-theme="dark"] .totals{color:#888}
:root[data-theme="dark"] .totals b{color:var(--fg)}
:root[data-theme="dark"] .totals.grand{border-top-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .discount-trigger{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .discount-trigger svg{color:#888}
:root[data-theme="dark"] .discount-trigger.has-value{border-color:var(--accent);color:#ff6680;background:#2a1a1a}
:root[data-theme="dark"] .discount-trigger.has-value svg{color:#ff6680}
:root[data-theme="dark"] .discount-trigger{color:#888}
:root[data-theme="dark"] .discount-trigger:active{color:var(--fg)}
:root[data-theme="dark"] .discount-trigger.has-value{color:#ff6680}
:root[data-theme="dark"] .discount-pills{background:#222;border-color:var(--muted)}
:root[data-theme="dark"] .discount-pill{color:#888}
:root[data-theme="dark"] .discount-pill.active{background:#181818;color:#ff6680;box-shadow:0 1px 2px rgba(0,0,0,.4)}
@ -392,30 +403,33 @@ input{font:inherit;color:inherit}
</div>
<div class="sheet-body" id="cartLines"></div>
<div class="sheet-foot">
<div class="totals"><span>Subtotal</span><b id="subTotal">R 0.00</b></div>
<!-- The discount trigger rides inside the Subtotal row so the footer
costs no extra height. Tapping it expands the R / % pills, number
input and Apply button below; the discounted amount is then shown
in red on its own totals line, and the server re-derives the
resolved rand value at commit time. -->
<div class="totals">
<span>Subtotal
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
<span id="discountTriggerLabel">Add discount</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
</button>
</span>
<b id="subTotal">R 0.00</b>
</div>
<div class="totals"><span>VAT 15%</span><b id="vatTotal">R 0.00</b></div>
<!-- Discount row: collapsed by default ("Add discount"),
expanded when the operator taps. Two-pill toggle
(R / %), number input, Apply / Clear. The discounted
amount is shown in red on the total line below. -->
<div class="discount" id="discountBlock">
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
<span id="discountTriggerLabel">Add discount</span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
</button>
<div class="discount-body" id="discountBody" hidden>
<div class="discount-pills" role="tablist">
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
</div>
<div class="discount-input-row">
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
<span class="discount-suffix" id="discSuffix">R</span>
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
</div>
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
<div class="discount-body" id="discountBody" hidden>
<div class="discount-pills" role="tablist">
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
</div>
<div class="discount-input-row">
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
<span class="discount-suffix" id="discSuffix">R</span>
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
</div>
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
</div>
<div class="totals discount-line" id="discountLine" hidden>
@ -496,11 +510,7 @@ input{font:inherit;color:inherit}
<label for="editQty">Quantity</label>
<input id="editQty" type="text" inputmode="decimal" autocomplete="off" data-od-id="edit-qty">
</div>
<div class="edit-row">
<label for="editPrice">Unit price (R)</label>
<input id="editPrice" type="text" inputmode="decimal" autocomplete="off" data-od-id="edit-price">
</div>
<p class="discount-hint" id="editHint">Hold to edit again, or change a value and tap Save.</p>
<p class="discount-hint" id="editHint">Type a quantity like 800, then tap Save.</p>
<div class="edit-actions">
<button class="danger" data-od-id="edit-remove" onclick="editRemove()">Remove</button>
<button class="secondary" data-od-id="edit-cancel" onclick="closeEditLine()">Cancel</button>

View file

@ -7,8 +7,30 @@
// running the step-1 build of it indefinitely.
// Measured on the live catalog: "nuts m20" 0 → 12, "washers flat" 0 → 14,
// with nothing that used to match ceasing to match.
// Bumped so tills evict the stale app shell.
const CACHE = "pos-v30";
// v33 — the Edit line sheet is quantity-only. The Unit price field is gone
// on request: the till sells at the catalogue shelf price, and a mis-keyed
// price is expensive and easy to miss. The per-line price override it fed is
// removed with it — the server only ever read `unit_price`, and the
// "price edited" label plus the sale record's `edited` flag are now
// unreachable. Nothing else could set an override, including a cart reload.
// v32 — fix the Edit line sheet, which never actually displayed. #editSheet
// is a sibling of #editBackdrop, but the CSS revealed it with
// `.edit-backdrop.show .edit-sheet`, a descendant selector that can never
// match a sibling — so the sheet sat at opacity:0 while the backdrop went
// opaque. Long-pressing a cart row produced a grey screen with no dialog.
// The sheet now carries its own z-index above the backdrop and its own
// .show rule. This is why manual quantity entry looked missing: the control
// existed and worked, it was just rendering underneath its own backdrop.
// Bumped because pos.html is served cache-first out of the shell.
// v31 — cart quantity is now directly editable: the number in each cart line
// is a real button that opens the existing Edit line sheet (previously only
// reachable by an undiscoverable 600ms long-press), and holding +/- no
// longer pops that sheet open mid-tap. The discount trigger moved out of its
// own 54px footer row to ride inside the Subtotal row, so it costs no
// vertical height while staying visible.
// Bumped because pos-app.js and pos.html are both served cache-first out of
// the shell; without a new CACHE name the tills keep the old ones.
const CACHE = "pos-v33";
const APP_SHELL = [
'./',
'./index.html',

View file

@ -0,0 +1,214 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* POS cart quantity entry (`public/pos/pos-app.js` + `public/pos/pos.html`).
*
* Ringing up 800 of something means typing 800, and the only route to the
* quantity field used to be a 600ms long-press on the row. The number
* itself was a plain `<div class="v">` — exactly the thing a cashier reaches
* for — so it rendered inert. The sheet it opens is unchanged and still
* reachable both ways; these guards exist because the *discoverability* is
* what regressed, and that is invisible in a code review: nothing throws,
* the build is green, and the control is simply dead to a first-time user.
*
* Same shape of problem as `pos-inline-handlers.test.ts`: assert the
* affordance exists rather than trusting that it was left in place.
*/
const POS_APP = readFileSync(join(process.cwd(), "public", "pos", "pos-app.js"), "utf8");
const POS_HTML = readFileSync(join(process.cwd(), "public", "pos", "pos.html"), "utf8");
describe("POS cart quantity is directly settable", () => {
it("renders the quantity as a button, not an inert div", () => {
expect(POS_APP).toMatch(/<button class="v"[^>]*onclick="window\.__pos\.openEditLine\(/);
expect(POS_APP).not.toMatch(/<div class="v">\$\{qty\}<\/div>/);
});
it("gives the quantity button an accessible label", () => {
// An unlabelled button reads as just "7" to a screen reader, which is
// no better than the div it replaced.
expect(POS_APP).toMatch(/aria-label="Set quantity for /);
});
it("keeps the steppers as +/- only", () => {
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',-1\)/);
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',1\)/);
});
it("exposes openEditLine on the global the button calls", () => {
// The inline handler guard proves the name is reachable; this proves the
// specific member the button reaches for exists.
expect(POS_APP).toMatch(/window\.__pos\s*=\s*\{[^}]*openEditLine/);
});
it("still opens the sheet on long-press", () => {
expect(POS_APP).toMatch(/startLongPress\(line\)/);
expect(POS_APP).toMatch(/openEditLine\(id\)/);
});
});
describe("POS cart long-press does not hijack the quantity controls", () => {
/** The pointerdown listener body, up to its options argument. */
const pointerdownHandler = () => {
const m = POS_APP.match(
/addEventListener\('pointerdown'[\s\S]*?\{ passive: true \}\);/,
);
expect(m, "pointerdown handler not found").not.toBeNull();
return m![0];
};
it("bails out of the pointerdown handler for .qty and .rm", () => {
// Holding "+" to ring up bulk used to pop the edit sheet open at 600ms,
// mid-tap. The steppers and the new quantity button each handle their
// own tap, so they must not start the row long-press.
expect(pointerdownHandler()).toMatch(/closest\('\.qty, \.rm'\)/);
});
it("bails BEFORE starting the timer, not after", () => {
// Order matters: an early `return` leaves no pending timer to fire.
const handler = pointerdownHandler();
const guardAt = handler.indexOf("closest('.qty, .rm')");
const startAt = handler.indexOf("startLongPress(line)");
expect(guardAt, "guard not found in handler").toBeGreaterThan(-1);
expect(startAt, "startLongPress not found in handler").toBeGreaterThan(-1);
expect(guardAt, "guard must come before startLongPress").toBeLessThan(startAt);
});
it("still falls through to the row long-press for a tap on the row body", () => {
// The guard must not disable the long-press gesture entirely.
expect(pointerdownHandler()).toMatch(/startLongPress\(line\)/);
});
});
describe("POS discount trigger costs no footer height", () => {
it("sits inside the Subtotal row rather than its own row", () => {
// The standalone .discount block was ~54px of always-on footer
// (12px padding + a 42px min-height button). The trigger now rides
// inside the existing Subtotal row, so it adds nothing vertically.
expect(POS_HTML).not.toMatch(/class="discount"/);
expect(POS_HTML).toMatch(/<div class="totals">\s*<span>Subtotal\s*<button class="discount-trigger"/);
});
it("keeps every id the discount JS depends on", () => {
// paintDiscount() and toggleDiscount() look these up by id; renaming any
// of them throws on drawer open, which would break the till.
for (const id of ["discountTrigger", "discountTriggerLabel", "discountBody"]) {
expect(POS_HTML, `missing #${id}`).toMatch(new RegExp(`id="${id}"`));
}
});
it("drops the old full-width button styling", () => {
expect(POS_HTML).not.toMatch(/\.discount-trigger\{[^}]*width:100%/);
expect(POS_HTML).toMatch(/\.discount-trigger\{[^}]*border:0/);
});
it("keeps the applied-discount totals line as the always-on indicator", () => {
// The trigger is a small link now; the red totals row is what proves a
// discount is live when nobody is looking at the footer.
expect(POS_HTML).toMatch(/id="discountLine"/);
expect(POS_HTML).toMatch(/class="totals discount-line"/);
});
});
describe("POS Edit line sheet is actually visible when opened", () => {
// This is the bug that hid manual quantity entry entirely: #editSheet is
// a SIBLING of #editBackdrop, but the CSS revealed it with
// `.edit-backdrop.show .edit-sheet` — a descendant selector that cannot
// match a sibling. The sheet stayed at opacity:0 while the backdrop went
// opaque, so long-pressing a cart row showed a grey screen with nothing
// on it. Nothing threw, the build was green, and the feature looked
// simply absent.
it("reveals the sheet with its own .show rule, not a descendant of the backdrop", () => {
expect(POS_HTML).toMatch(/^\.edit-sheet\.show\{/m);
expect(POS_HTML).not.toMatch(/^\.edit-backdrop\.show \.edit-sheet/m);
});
it("stacks the sheet above the backdrop", () => {
const backdrop = POS_HTML.match(/\.edit-backdrop\{[^}]*z-index:(\d+)/)?.[1];
const sheet = POS_HTML.match(/\.edit-sheet\{[^}]*z-index:(\d+)/)?.[1];
expect(backdrop, "backdrop z-index not found").toBeDefined();
expect(sheet, "sheet z-index not found — it would render under the backdrop").toBeDefined();
expect(Number(sheet)).toBeGreaterThan(Number(backdrop));
});
it("keeps the sheet anchored to the viewport, not the document", () => {
// position:absolute resolved against an unpredictable ancestor.
expect(POS_HTML).toMatch(/\.edit-sheet\{position:fixed/);
});
it("still opens and closes both elements from JS", () => {
// openEditLine/closeEditLine add .show to the backdrop AND the sheet —
// that part was always right, which is why the bug looked like a
// missing feature rather than a broken one.
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.add\('show'\)/);
expect(POS_APP).toMatch(/editSheet'\)\.classList\.add\('show'\)/);
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.remove\('show'\)/);
expect(POS_APP).toMatch(/editSheet'\)\.classList\.remove\('show'\)/);
});
it("keeps them siblings so a tap on the sheet does not close it", () => {
// #editBackdrop carries onclick="closeEditLine()". Nesting the sheet
// inside it — the "obvious" fix — would make every tap on Save bubble
// to the backdrop and dismiss the dialog first. The backdrop must
// therefore stay an empty, immediately-closed element.
const backdropTag = POS_HTML.match(/<div class="edit-backdrop"[^>]*>/)?.[0];
expect(backdropTag, "edit-backdrop not found").toBeDefined();
expect(backdropTag).toMatch(/id="editBackdrop"/);
const after = POS_HTML.slice(
POS_HTML.indexOf(backdropTag!) + backdropTag!.length,
);
expect(after.startsWith("</div>"), "backdrop must close immediately").toBe(true);
});
});
describe("POS Edit line popup is quantity-only", () => {
// Requested: no price editing from the till. The override it used to set
// was also never persisted (persistCart saves only {id, qty, p}), so it
// was a session-only thing that produced a sale record the server
// ignored. Removing it is a deletion, not a feature loss.
it("has no price field in the sheet", () => {
expect(POS_HTML).not.toMatch(/id="editPrice"/);
expect(POS_HTML).not.toMatch(/Unit price/);
expect(POS_APP).not.toMatch(/getElementById\('editPrice'\)/);
});
it("keeps the quantity field", () => {
expect(POS_HTML).toMatch(/id="editQty"/);
expect(POS_APP).toMatch(/getElementById\('editQty'\)\.value = String\(line\.qty\)/);
});
it("removes the override mechanism entirely", () => {
// Nothing else could set it — not a cart reload, not addToCart — so
// leaving the field in place would be dead code implying the till can
// still change a price.
expect(POS_APP).not.toMatch(/unitPriceOverride/);
expect(POS_HTML).not.toMatch(/price edited/);
});
it("still sends a price, and it is the shelf price", () => {
// /checkout requires unit_price; dropping the override must not drop
// the price itself.
expect(POS_APP).toMatch(/unit_price: lineUnitPrice\(\{p,qty\}\)/);
expect(POS_APP).toMatch(/selling_price: lineUnitPrice\(\{p,qty\}\)/);
expect(POS_APP).toMatch(
/function lineUnitPrice\(line\)\{\s*return Number\(line\.p\.selling_price/,
);
});
it("no longer sends the unused `edited` flag", () => {
// pos-service.ts never read it — only `unit_price` matters.
expect(POS_APP).not.toMatch(/\bedited:/);
});
});
describe("POS service worker cache tracks shell edits", () => {
it("is past v30, since pos-app.js and pos.html are served cache-first", () => {
const v = POS_HTML; // placeholder to keep the import honest
expect(v).toBeDefined();
const sw = readFileSync(join(process.cwd(), "public", "pos", "sw.js"), "utf8");
const m = sw.match(/const CACHE = "pos-v(\d+)"/);
expect(m, "sw.js CACHE constant not found").not.toBeNull();
expect(Number(m![1])).toBeGreaterThanOrEqual(33);
});
});