PosRegister
A tablet till: a product grid, ticket, quantity and discount, cash and card, in decimals, keeping the sale through a reload.
Demo
Ticket
0 items- Tap a product to start a sale.
- Subtotal
- $0.00
- Discount
- $0.00
- Tax
- $0.00
- Total
- $0.00
No sale yet.
0 card attempts.
Ticket
0 items- Tap a product to start a sale.
- Subtotal
- ¥0
- Discount
- ¥0
- Tax included
- ¥0
- Total
- ¥0
No products to show.
Ticket
2 items- 2$5.00
- Subtotal
- $5.00
- Discount
- $5.00
- Tax
- $0.00
- Total
- $0.00
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/pos_register.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/pos_register.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/pos_register.qwe
let pos_register_demo_products = [ { id: "fw", name: "Flat white", price: "4.50", category: "Coffee" }, { id: "lt", name: "Latte", price: "4.75", category: "Coffee" }, { id: "es", name: "Espresso", price: "3.20", category: "Coffee" }, { id: "cr", name: "Croissant", price: "3.75", category: "Bakery" }, { id: "mf", name: "Blueberry muffin", price: "3.95", category: "Bakery" }, { id: "sc", name: "Scone", price: "3.10", category: "Bakery" }, { id: "tw", name: "Toastie", price: "7.60", category: "Kitchen" }, { id: "sp", name: "Soup of the day", price: "6.85", category: "Kitchen" }, { id: "wt", name: "Water", price: "0.10", category: "Drinks" }, { id: "oj", name: "Orange juice", price: "3.30", category: "Drinks" }, { id: "bad", name: "No price" }, { id: "bad2", name: "Bad price", price: "abc" }, { id: "bad3", name: "Exponent", price: "1e3" }, { id: "fw", name: "Flat white again", price: "9.99" },]component PosRegisterDemoLive() { state note = "No sale yet." state refuse = false state charges = 0 state reader = null if reader != null { every(150, () => { let answer = reader reader = null answer(!refuse) }) } <div.demo-stack> <div.demo-tablet> <PosRegister products={pos_register_demo_products} tax_rate="0.0825" storage_key="pos-demo" on_card={(_amount, _ref) => { charges += 1 // A reader takes a moment, and a second tap in it must not charge twice. js("Promise.resolve", js_new("Promise", (ok) => { reader = ok })) }} on_sale={(d) => { note = `${d.paid_by}: ${d.total.amount}, change ${d.change != null ? d.change.amount : "none"}, ${len(d.lines)} lines.` }}/> </div> <button id="pos-refuse" style="min-height: 44px; min-width: 44px" @click={() => { refuse = !refuse }}>{refuse ? "Card refused" : "Card accepted"}</button> <p.demo-note id="pos-register-note">{note}</p> <p.demo-note id="pos-register-charges">{charges} card attempts.</p> <div id="pos-register-yen" hidden="true"><PosRegister products={[{ id: 1, name: "Ramen", price: 1250 }, { id: 2, name: "Tea", price: "333" }]} currency="JPY" locale="ja-JP" tax_rate="0.1" tax_included={true} storage_key={null}/></div> <div id="pos-register-held" hidden="true"><PosRegister storage_key={null} products={[]} currency="XXQ" tax_rate="soon" saved={{ ref: "held-1", lines: [{ id: "a", name: "Held", price: "2.50", qty: 2 }, { id: "b", name: "Bad qty", price: "1", qty: 0 }, { id: "c", name: "Bad price", price: "x", qty: 1 }, null], off: "100.5", off_kind: "pct" }}/></div> </div>}view PosRegisterDemo() = <PosRegisterDemoLive/>Source
ui/pos_register.qwe
// PosRegister: a till for a tablet on a counter. A product grid beside a ticket, quantity and// discount by steppers and a number pad, tax, then cash with change or card. All the// sums are decimals in the currency's own units, so a sale adds up to the cent, and an// unfinished sale survives a reload.//// import ui/pos_register.qwe use PosRegister//// component Till(products: list) {// state held = load_open_sale()// <div style="height: 100dvh">// <PosRegister products={products} tax_rate="0.0825" saved={held} on_change={(sale) => { keep_open_sale(sale) }}// on_card={(amount, ref) => { charge(amount, ref) }} on_sale={(done) => { record_sale(done) }}/>// </div>// }//// products: each { id, name, price, category? }. price is text or a decimal ("3.50", 3.50m); a// float works but is a float. One with no name or no usable price is left out, as is an id// already seen. currency: ISO code ("USD"). locale: how amounts are written ("en-US").// tax_rate: a share of the price, as text ("0.0825" is 8.25%; 0 for none). tax_included: true// when prices already hold the tax, so it is worked out of them, and the total is the// sum. label: its name for screen readers. class.// saved: an unfinished sale to carry on with, as on_change gave it; it wins over the browser's// own copy. storage_key: where that copy is kept ("quillo-pos-sale"; null for none).// on_change(sale): inside one of your components, called with { ref, lines, off_kind, off } after// each change. In a phone app the browser's copy lasts one launch, so an app keeps this// through a server fn and passes it back as saved.// on_card(amount, ref): what charges a card, given the total as money and the sale's own// reference, kept through a reload, so a retry can be told from a new charge. It may wait,// and answers true, or { ok: true }; anything else is a refusal, which keeps the sale. Without// it the till asks whether the terminal beside it approved. on_sale(done): called once with// { ref, lines, subtotal, discount, tax, total, paid_by, tendered, change, at }, in money.//// Tax is worked on the whole sale after discounts, half up, a discount in percent likewise,// each to the currency's smallest unit. An amount off never takes a line or a sale below zero.// Keys on the pad: digits, the point, Backspace, Enter and Escape.import ui/theme.qwe use ui_clslet pos_register_css = css`.ui-pos-register { position: relative; box-sizing: border-box; display: flex; width: 100%; height: 100%; min-height: 420px; container-type: inline-size; color: var(--ui-fg); background: var(--ui-subtle); font: 15px/1.3 var(--ui-font); }.ui-pos-register-main { display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; }.ui-pos-register-shop { display: flex; flex-direction: column; flex: 1 1 44%; min-width: 0; min-height: 0; }.ui-pos-register-cats { display: flex; flex: none; gap: 4px; overflow-x: auto; overscroll-behavior-x: contain; padding: 8px max(8px, env(safe-area-inset-left)); scrollbar-width: none; }.ui-pos-register-cat { flex: none; min-width: 44px; min-height: 44px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 14px var(--ui-font); cursor: pointer; }.ui-pos-register-cat[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-pos-register-cat:focus-visible, .ui-pos-register-item:focus-visible, .ui-pos-register-key:focus-visible, .ui-pos-register-btn:focus-visible, .ui-pos-register-line-pick:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-pos-register-grid { flex: 1; display: grid; grid-template-columns: repeat(auto-fill, minmax(124px, 1fr)); grid-auto-rows: 84px; gap: 8px; align-content: start; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 8px 8px max(8px, env(safe-area-inset-left)); }.ui-pos-register-item { display: flex; flex-direction: column; justify-content: space-between; min-width: 0; padding: 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }.ui-pos-register-item:active { background: var(--ui-accent-soft); }.ui-pos-register-item b { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-pos-register-item span { color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-pos-register-ticket { display: flex; flex-direction: column; flex: 1 1 56%; min-width: 0; min-height: 0; border-top: 1px solid var(--ui-border); background: var(--ui-surface); }.ui-pos-register-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex: none; min-height: 48px; padding: 2px 12px; border-bottom: 1px solid var(--ui-border); }.ui-pos-register-head h3 { margin: 0; font-size: 16px; }.ui-pos-register-split { display: flex; flex: 1; min-height: 0; }.ui-pos-register-lines { flex: 1; min-width: 0; margin: 0; padding: 4px; overflow-y: auto; overscroll-behavior: contain; list-style: none; }.ui-pos-register-empty { padding: 24px 12px; color: var(--ui-muted); text-align: center; }.ui-pos-register-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; padding: 2px 4px; border-radius: var(--ui-radius-sm); }.ui-pos-register-line-on { background: var(--ui-accent-soft); }.ui-pos-register-line-pick { flex: 1 1 120px; min-width: 0; min-height: 44px; padding: 2px 4px; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }.ui-pos-register-line-pick b { display: block; overflow: hidden; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }.ui-pos-register-line-pick small { color: var(--ui-muted); font-size: 12px; }.ui-pos-register-qty { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; }.ui-pos-register-net { min-width: 72px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }.ui-pos-register-btn { min-width: 44px; min-height: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 14px var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-pos-register-btn:disabled { opacity: .5; cursor: default; }.ui-pos-register-btn[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-pos-register-primary { border-color: var(--ui-accent); background: var(--ui-accent); color: var(--ui-accent-fg); }.ui-pos-register-side { display: flex; flex-direction: column; flex: 0 0 min(300px, 44%); min-height: 0; overflow-y: auto; border-left: 1px solid var(--ui-border); }.ui-pos-register-sums { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; margin: 0; padding: 8px 12px; font-variant-numeric: tabular-nums; }.ui-pos-register-sums dt { color: var(--ui-muted); }.ui-pos-register-sums dd { margin: 0; text-align: right; }.ui-pos-register-total { padding-top: 4px; color: var(--ui-fg) !important; font-size: 20px; font-weight: 700; }.ui-pos-register-acts { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 6px 12px calc(10px + env(safe-area-inset-bottom)); }.ui-pos-register-wide { grid-column: 1 / -1; }.ui-pos-register-panel { display: grid; gap: 8px; padding: 8px 12px calc(10px + env(safe-area-inset-bottom)); outline: none; }.ui-pos-register-panel h4 { margin: 0; font-size: 15px; }.ui-pos-register-entry { min-height: 44px; padding: 6px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font: 600 22px/1.4 var(--ui-font); font-variant-numeric: tabular-nums; text-align: right; }.ui-pos-register-keys { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }.ui-pos-register-key { min-height: 52px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-fg); font: 600 19px var(--ui-font); cursor: pointer; touch-action: manipulation; }.ui-pos-register-row { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 6px; }.ui-pos-register-msg { margin: 0; color: var(--ui-muted); font-size: 14px; }.ui-pos-register-bad { color: var(--ui-danger); }.ui-pos-register-change { font-size: 30px; font-weight: 700; font-variant-numeric: tabular-nums; text-align: center; }/* With the width of a turned tablet the ticket stands beside the grid and the sums under the lines. */@container (min-width: 960px) { .ui-pos-register-main { flex-direction: row; } .ui-pos-register-shop { flex: 1 1 56%; } .ui-pos-register-ticket { flex: 0 0 440px; border-top: 0; border-left: 1px solid var(--ui-border); } .ui-pos-register-split { flex-direction: column; } .ui-pos-register-side { flex: 0 1 auto; max-height: 72%; border-top: 1px solid var(--ui-border); border-left: 0; }}`// A currency this till can count in, or USD: an unknown code stops every money call.fn pos_register_currency(code) -> str { if type_of(code) != "str" { return "USD" } type_of(money.of(0, code)) == "error" ? "USD" : code}// Text, a decimal, an int or a float as a decimal at most 12 digits long, or null. A text like// "1e3" or "abc" is not a price.fn pos_register_dec(v) { let t = type_of(v) if t == "dec" { return abs(v) < 1000000000m ? v : null } if t == "int" || t == "float" { return pos_register_dec(str(v)) } if t != "str" { return null } let text = trim(v) if len(text) == 0 || len(text) > 13 || !re.test(text, "^-?[0-9]{1,12}(\\.[0-9]{1,6})?$") { return null } pos_register_dec(dec(text))}// The products that can be sold, ids as text, prices decimals rounded to the currency.fn pos_register_products(products, cur: str) -> list { if type_of(products) != "list" { return [] } let usable = products | filter((p) => type_of(p) == "map" && type_of(get(p, "name")) == "str" && pos_register_dec(get(p, "price")) != null && pos_register_dec(get(p, "price")) >= 0m) let all = enumerate(usable) | map((p) => { let x = p[1] { id: get(x, "id") != null ? str(x.id) : `_${p[0]}`, name: x.name, category: type_of(get(x, "category")) == "str" ? x.category : "Other", price: money.of(pos_register_dec(x.price), cur, "half_up") } }) let ids = all | map((x) => x.id) enumerate(all) | filter((p) => index_of(ids, p[1].id) == p[0]) | map((p) => p[1])}// The rate as a decimal share between 0 and 1.fn pos_register_rate(rate) { let d = pos_register_dec(rate) if d == null || d < 0m { return 0m } d > 1m ? 1m : d}// An empty sale.fn pos_register_blank() { { ref: "", lines: [], off_kind: "pct", off: "0" }}// A sale from outside or from storage: lines that make sense, the rest left out. Prices stay as// rung up, even if the product list has changed since.fn pos_register_clean(sale) { if type_of(sale) != "map" { return pos_register_blank() } let lines = get(sale, "lines") let shaped = type_of(lines) != "list" ? [] : lines | filter((l) => { if type_of(l) != "map" || type_of(get(l, "name")) != "str" { return false } let q = get(l, "qty") let price = pos_register_dec(get(l, "price")) type_of(q) == "int" && q >= 1 && q <= 9999 && price != null && price >= 0m && get(l, "id") != null }) let kept = shaped | map((l) => { { id: str(l.id), name: l.name, price: str(pos_register_dec(l.price)), qty: l.qty, off_kind: get(l, "off_kind") == "amt" ? "amt" : "pct", off: str(pos_register_dec(get(l, "off")) ?? 0m) } }) { ref: type_of(get(sale, "ref")) == "str" ? sale.ref : "", lines: kept, off_kind: get(sale, "off_kind") == "amt" ? "amt" : "pct", off: str(pos_register_dec(get(sale, "off")) ?? 0m) }}// What a discount takes off an amount: a percentage, half up, or an amount; never more than it.fn pos_register_off(gross, kind: str, off: str, cur: str) { let d = pos_register_dec(off) ?? 0m if d <= 0m { return money.zero(cur) } if kind == "pct" { let share = (d > 100m ? 100m : d) / 100 return money.mul(gross, share, "half_up") } let amount = money.of(d, cur, "half_up") money.compare(amount, gross) > 0 ? gross : amount}// A line's sums: { gross, off, net }.fn pos_register_line(l, cur: str) { let price = money.of(pos_register_dec(l.price) ?? 0m, cur, "half_up") let gross = money.mul(price, l.qty) let off = pos_register_off(gross, l.off_kind, l.off, cur) { gross, off, net: money.sub(gross, off) }}// The whole sale: { count, subtotal, discount, tax, total }. subtotal is before the sale's// discount and after the lines'. Tax is on what is left, out of it when prices include it.fn pos_register_totals(sale, rate, included: bool, cur: str) { let nets = sale.lines | map((l) => pos_register_line(l, cur).net) let subtotal = reduce(nets, money.zero(cur), (a, b) => money.add(a, b)) let discount = pos_register_off(subtotal, sale.off_kind, sale.off, cur) let taxable = money.sub(subtotal, discount) let count = sum(sale.lines | map((l) => l.qty)) if included { let net = dec(1) + rate let before = money.of(taxable.amount / net, cur, "half_up") return { count, subtotal, discount, tax: money.sub(taxable, before), total: taxable } } let tax = money.mul(taxable, rate, "half_up") { count, subtotal, discount, tax, total: money.add(taxable, tax) }}// The sale with one more of a product: a line it has, else a new one at the end.fn pos_register_add(sale, p) { let has = find(sale.lines, (l) => l.id == p.id) != null let lines = has ? (sale.lines | map((l) => l.id == p.id ? { ...l, qty: min([l.qty + 1, 9999]) } : l)) : sale.lines | push({ id: p.id, name: p.name, price: str(p.price.amount), qty: 1, off_kind: "pct", off: "0" }) { ...sale, lines }}// The sale with a line's quantity set; none takes the line off.fn pos_register_set_qty(sale, id: str, qty: int) { if qty <= 0 { return { ...sale, lines: sale.lines | filter((l) => l.id != id) } } { ...sale, lines: sale.lines | map((l) => l.id == id ? { ...l, qty: min([qty, 9999]) } : l) }}// A key on the pad applied to what has been typed. places: decimals allowed; a point is// refused at none. Nine characters are as many as a till needs.fn pos_register_type(entry: str, key: str, places: int) -> str { if key == "C" { return "" } if key == "del" { return slice(entry, 0, max([0, len(entry) - 1])) } if key == "." { if places == 0 || contains(entry, ".") { return entry } return entry == "" ? "0." : entry + "." } if len(entry) >= 9 { return entry } let parts = split(entry, ".") if len(parts) == 2 && len(parts[1]) >= places { return entry } entry == "0" ? key : entry + key}// Notes a customer is likely to hand over for a total: the next round amounts.fn pos_register_notes(total, places: int) -> list { let scale = places == 0 ? 100 : 1 let steps = [5, 10, 20, 50, 100] | map((s) => dec(s * scale)) let ups = steps | map((s) => ceil(total / s) * s) slice(unique(ups), 0, 4)}component PosRegister(products = [], currency: str = "USD", locale: str = "en-US", tax_rate = "0", tax_included: bool = false, label: str = "Till", saved = null, storage_key: str? = "quillo-pos-sale", on_change = null, on_card = null, on_sale = null, class: str? = null) { style global pos_register_css let cur = pos_register_currency(currency) let places = money.minor_units(cur) let rate = pos_register_rate(tax_rate) let stock = pos_register_products(products, cur) let cats = ["All"] + unique(stock | map((p) => p.category)) state cat = "All" state sale = pos_register_clean(saved) state picked = "" // the line picked state pad = null // { what, title } while the number pad is out state entry = "" state pay = "" // "cash" or "card" while paying state busy = false state msg = "" state done = null // the sale just finished, for the screen after it state unkept = false // the browser would not keep the sale state said = "" let shown = cat == "All" ? stock : (stock | filter((p) => p.category == cat)) let totals = pos_register_totals(sale, rate, tax_included, cur) let line_sums = sale.lines | map((l) => pos_register_line(l, cur)) let fmt = (m) => money.format(m, locale) let total_due = totals.total let tendered = pos_register_dec(entry) ?? 0m let is_short = tendered < total_due.amount // Kept here as well as told, so a reload finds the sale. Storage may refuse, or not be there. let keep = () => { on_change && on_change(sale) if storage_key != null { let key = storage_key let text = json.stringify(sale) let r = js("Promise.resolve", js_new("Promise", (ok) => { ok(js("localStorage.setItem", key, text)) })) unkept = is_err(r) } } let change = (next) => { sale = len(next.lines) > 0 && next.ref == "" ? { ...next, ref: uuid() } : next keep() } let open_pad = (what: str, title: str, start: str) => { pad = { what, title } entry = start msg = "" } let picked_line = () => find(sale.lines, (l) => l.id == picked) let apply_pad = () => { let p = pad let d = pos_register_dec(entry) ?? 0m if p.what == "qty" { change(pos_register_set_qty(sale, picked, int(d))) } if p.what == "line_pct" || p.what == "line_amt" { let kind = p.what == "line_pct" ? "pct" : "amt" let off = str(d) change({ ...sale, lines: sale.lines | map((l) => l.id == picked ? { ...l, off_kind: kind, off } : l) }) } if p.what == "sale_pct" || p.what == "sale_amt" { change({ ...sale, off_kind: p.what == "sale_pct" ? "pct" : "amt", off: str(d) }) } pad = null entry = "" said = `Total ${fmt(pos_register_totals(sale, rate, tax_included, cur).total)}.` } let press = (k: str) => { let allowed = pad != null && pad.what == "qty" ? 0 : places entry = pos_register_type(entry, k, allowed == 0 && pad != null && (pad.what == "line_pct" || pad.what == "sale_pct") ? 2 : allowed) } let finish = (paid_by: str, given, back) => { let t = totals let record = { ref: sale.ref, lines: sale.lines, subtotal: t.subtotal, discount: t.discount, tax: t.tax, total: t.total, paid_by, tendered: given, change: back, at: js("Date.now") } done = record pay = "" pad = null entry = "" picked = "" msg = "" sale = pos_register_blank() keep() said = `Paid. ${back != null ? `Change ${fmt(back)}.` : ""}` on_sale && on_sale(record) } let pay_cash = (amount) => { let given = money.of(amount, cur, "half_up") if !busy && money.compare(given, total_due) >= 0 { finish("cash", given, money.sub(given, total_due)) } } let pay_card = () => { if !busy { busy = true msg = "" let amount = total_due let ref = sale.ref let got = js("Promise.resolve", on_card(amount, ref)) busy = false if got == true || (type_of(got) == "map" && get(got, "ok") == true) { finish("card", amount, null) } else { msg = "The card was not accepted. The sale is kept." } } } let on_pad_key = (e) => { if len(e.key) == 1 && e.key >= "0" && e.key <= "9" { e.preventDefault() press(e.key) } else if e.key == "." { e.preventDefault() press(".") } else if e.key == "Backspace" { e.preventDefault() press("del") } else if e.key == "Enter" { e.preventDefault() if pad != null { apply_pad() } else if pay == "cash" { pay_cash(tendered) } } else if e.key == "Escape" { e.preventDefault() pad = null pay = "" entry = "" } } <div class={ui_cls(["ui-pos-register", class])} role="group" aria-label={label} @mount={(_me) => { if saved == null && storage_key != null { let key = storage_key let text = js("Promise.resolve", js_new("Promise", (ok) => { ok(js("localStorage.getItem", key)) })) if type_of(text) == "str" { let was = json.parse(text) if !is_err(was) { sale = pos_register_clean(was) } } } }}> <div.ui-pos-register-main> <div.ui-pos-register-shop> <div.ui-pos-register-cats role="group" aria-label="Categories"> {for c in cats => <button.ui-pos-register-cat key={c} type="button" aria-pressed={cat == c ? "true" : "false"} @click={() => { cat = c }}>{c}</button>} </div> <div.ui-pos-register-grid> {for p in shown => <button.ui-pos-register-item key={p.id} type="button" disabled={done != null} @click={() => { change(pos_register_add(sale, p)) picked = p.id said = `${p.name} added.` }}><b>{p.name}</b><span>{fmt(p.price)}</span></button>} {len(shown) == 0 && <p.ui-pos-register-empty>No products to show.</p>} </div> </div> <section.ui-pos-register-ticket aria-label="Ticket"> <div.ui-pos-register-head> <h3>Ticket</h3> <span>{totals.count} items</span> <button.ui-pos-register-btn type="button" disabled={len(sale.lines) == 0 || pay != ""} @click={() => { change(pos_register_blank()) picked = "" pad = null said = "Sale voided." }}>Void</button> </div> <div.ui-pos-register-split> <ul.ui-pos-register-lines> {for p in enumerate(sale.lines) => <li key={p[1].id} class={ui_cls(["ui-pos-register-line", picked == p[1].id && "ui-pos-register-line-on"])} data-id={p[1].id}> <button.ui-pos-register-line-pick type="button" aria-pressed={picked == p[1].id ? "true" : "false"} @click={() => { picked = picked == p[1].id ? "" : p[1].id }}> <b>{p[1].name}</b> <small>{fmt(money.of(pos_register_dec(p[1].price) ?? 0m, cur, "half_up"))}{!money.is_zero(line_sums[p[0]].off) ? ` less ${fmt(line_sums[p[0]].off)}` : ""}</small> </button> <button.ui-pos-register-btn type="button" aria-label={`One fewer ${p[1].name}`} @click={() => { change(pos_register_set_qty(sale, p[1].id, p[1].qty - 1)) }}>-</button> <span.ui-pos-register-qty>{p[1].qty}</span> <button.ui-pos-register-btn type="button" aria-label={`One more ${p[1].name}`} @click={() => { change(pos_register_set_qty(sale, p[1].id, p[1].qty + 1)) }}>+</button> <span.ui-pos-register-net>{fmt(line_sums[p[0]].net)}</span> </li>} {len(sale.lines) == 0 && done == null && <li.ui-pos-register-empty>Tap a product to start a sale.</li>} </ul> <div.ui-pos-register-side> <dl.ui-pos-register-sums> <dt>Subtotal</dt><dd data-sum="subtotal">{fmt(totals.subtotal)}</dd> <dt>Discount</dt><dd data-sum="discount">{fmt(totals.discount)}</dd> <dt>{tax_included ? "Tax included" : "Tax"}</dt><dd data-sum="tax">{fmt(totals.tax)}</dd> <dt class="ui-pos-register-total">Total</dt><dd class="ui-pos-register-total" data-sum="total">{fmt(totals.total)}</dd> </dl> {done != null && <div.ui-pos-register-panel> <h4>Paid by {done.paid_by}</h4> <p.ui-pos-register-msg>{fmt(done.total)} taken{done.tendered != null ? `, ${fmt(done.tendered)} given` : ""}</p> {done.change != null && <div.ui-pos-register-change>Change {fmt(done.change)}</div>} <button.ui-pos-register-btn.ui-pos-register-primary type="button" @click={() => { done = null }}>New sale</button> </div>} {done == null && pad != null && <div.ui-pos-register-panel tabindex="-1" @mount={(me) => { me.focus({ preventScroll: true }) }} @keydown={on_pad_key}> <h4>{pad.title}</h4> <div.ui-pos-register-entry aria-live="polite">{entry == "" ? "0" : entry}</div> <div.ui-pos-register-keys> {for k in ["7", "8", "9", "4", "5", "6", "1", "2", "3", ".", "0", "del"] => <button.ui-pos-register-key key={k} type="button" aria-label={k == "del" ? "Delete" : k} @click={() => { press(k) }}>{k == "del" ? "Del" : k}</button>} </div> <div.ui-pos-register-row> <button.ui-pos-register-btn type="button" @click={() => { pad = null entry = "" }}>Cancel</button> <button.ui-pos-register-btn.ui-pos-register-primary type="button" @click={apply_pad}>Apply</button> </div> </div>} {done == null && pad == null && pay == "cash" && <div.ui-pos-register-panel tabindex="-1" @mount={(me) => { me.focus({ preventScroll: true }) }} @keydown={on_pad_key}> <h4>Cash given</h4> <div.ui-pos-register-row> <button.ui-pos-register-btn type="button" @click={() => { pay_cash(total_due.amount) }}>Exact</button> {for n in pos_register_notes(total_due.amount, places) => <button.ui-pos-register-btn key={str(n)} type="button" @click={() => { pay_cash(n) }}>{format_number(n, locale, 0)}</button>} </div> <div.ui-pos-register-entry aria-live="polite">{entry == "" ? "0" : entry}</div> <div.ui-pos-register-keys> {for k in ["7", "8", "9", "4", "5", "6", "1", "2", "3", ".", "0", "del"] => <button.ui-pos-register-key key={k} type="button" aria-label={k == "del" ? "Delete" : k} @click={() => { press(k) }}>{k == "del" ? "Del" : k}</button>} </div> <p class={ui_cls(["ui-pos-register-msg", is_short && entry != "" && "ui-pos-register-bad"])} data-part="short">{is_short && entry != "" ? `Short by ${fmt(money.of(total_due.amount - tendered, cur, "half_up"))}` : " "}</p> <div.ui-pos-register-row> <button.ui-pos-register-btn type="button" @click={() => { pay = "" entry = "" }}>Back</button> <button.ui-pos-register-btn.ui-pos-register-primary type="button" disabled={is_short} @click={() => { pay_cash(tendered) }}>Take cash</button> </div> </div>} {done == null && pad == null && pay == "card" && <div.ui-pos-register-panel> <h4>Card</h4> <p class={ui_cls(["ui-pos-register-msg", msg != "" && "ui-pos-register-bad"])} data-part="card">{busy ? "Waiting for the card reader..." : (msg != "" ? msg : (on_card != null ? `Charge ${fmt(total_due)}.` : `Take ${fmt(total_due)} on the terminal, then confirm.`))}</p> <div.ui-pos-register-row> <button.ui-pos-register-btn type="button" disabled={busy} @click={() => { pay = "" msg = "" }}>Back</button> {on_card != null ? <button.ui-pos-register-btn.ui-pos-register-primary type="button" disabled={busy} @click={pay_card}>Charge</button> : <button.ui-pos-register-btn.ui-pos-register-primary type="button" @click={() => { finish("card", total_due, null) }}>Approved</button>} </div> </div>} {done == null && pad == null && pay == "" && <div.ui-pos-register-acts> {picked_line() != null && <button.ui-pos-register-btn type="button" @click={() => { open_pad("qty", `Quantity of ${picked_line().name}`, "") }}>Quantity</button>} {picked_line() != null && <button.ui-pos-register-btn type="button" @click={() => { open_pad("line_pct", `Percent off ${picked_line().name}`, "") }}>Line % off</button>} {picked_line() != null && <button.ui-pos-register-btn type="button" @click={() => { open_pad("line_amt", `Amount off ${picked_line().name}`, "") }}>Line amount off</button>} <button.ui-pos-register-btn type="button" disabled={len(sale.lines) == 0} @click={() => { open_pad("sale_pct", "Percent off the sale", "") }}>Sale % off</button> <button.ui-pos-register-btn type="button" disabled={len(sale.lines) == 0} @click={() => { open_pad("sale_amt", "Amount off the sale", "") }}>Sale amount off</button> <button.ui-pos-register-btn.ui-pos-register-primary type="button" disabled={len(sale.lines) == 0} @click={() => { pay = "cash" entry = "" }}>Cash</button> <button.ui-pos-register-btn.ui-pos-register-primary type="button" disabled={len(sale.lines) == 0} @click={() => { pay = "card" msg = "" }}>Card</button> {unkept && <p.ui-pos-register-msg.ui-pos-register-wide>This browser will not keep the sale through a reload.</p>} </div>} </div> </div> </section> </div> <span.ui-sr role="status">{said}</span> </div>}Related
CartSheetA cart in a sheet with quantities, swipe to remove with undo, live totals in decimals, a free-shipping note and a checkout button.2 views · 9 downloadsCheckoutSheetA one-screen checkout in a sheet with address, delivery, payment and a live total, every part validated, and a slide to pay.2 views · 6 downloadsFilterSheetFacets in a sheet, with result counts updating live, clear, apply, the count on the button, and a state kept for next time.3 views · 5 downloadsOrderStatusCardThe stages of an order with a progress bar, an estimate from data, and what to do when it is late.3 views · 6 downloadsScanToAddScan a product's code to add it to a list, by the camera, a photo or typing, with a beep, a haptic and a count of repeats.3 views · 7 downloadsSnapCarouselProduct pictures snapped by CSS with dots and a count, a tap opening a viewer to zoom, keeping its place, working without script.3 views · 6 downloads
Thread
Thread 0 posts
On PosRegister: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.