PayCode
A QR or code to pay with, refreshing on a timer, screen brightness up while shown, locked until confirmed.
Demo
Pay Cafe Luz$12.50
Shown 0, locked 0
Get it
It needs theme.qwe, as every component does, and qr_code.qwe and price.qwe; 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/qr_code.qwe$ curl -O https://quillolang.org/components/ui/price.qwe$ curl -O https://quillolang.org/components/ui/pay_code.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/pay_code.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/pay_code.qwe
component PayCodeDemoLive() { state paid = false state shows = 0 state locks = 0 state turn = 0 state down = false // Answers at once on even periods and later on odd ones, as a server fn would. let service = (step) => down ? null : step % 2 == 0 ? `40000000${step % 10000}` : js("Promise.resolve", js_new("Promise", (done) => { done(`40000000${step % 10000}`) })) <div.demo-stack> {turn % 2 == 0 ? <PayCode key={turn} amount={dec("12.50")} payee="Cafe Luz" seed="demo-seed" period={2} hold_ms={500} lock_after={7} confirmed={paid} on_show={() => { shows += 1 }} on_lock={() => { locks += 1 }}/> : <PayCode key={turn} amount={dec("12.50")} payee="Cafe Luz" period={2} lock_after={30} confirmed={paid} on_refresh={service}/>} <div.demo-row> <Button id="pay-demo-till" variant="outline" size="lg" on_click={() => { paid = true }}>The till confirms</Button> <Button id="pay-demo-again" variant="ghost" size="lg" on_click={() => { paid = false; turn += 2 }}>Start again</Button> <Button id="pay-demo-service" variant="ghost" size="lg" on_click={() => { paid = false; turn += 1 }}>Codes from a service</Button> <Button id="pay-demo-down" variant="ghost" size="lg" on_click={() => { down = !down }}>{down ? "Service back" : "Service down"}</Button> </div> <p.demo-note id="pay-demo-said">Shown {shows}, locked {locks}</p> </div>}view PayCodeDemo() = <PayCodeDemoLive/>Source
ui/pay_code.qwe
// PayCode: a code to pay with at a till: a QR code or its digits, new every so many// seconds. It stays locked behind a hold, shown when the finger lifts after it (or a// confirmation, for those who cannot hold) until the person says it is them, and locks again when the app leaves the screen, so a// phone left on a table shows nothing. While shown, the screen goes to full brightness// for the reader, and back after. When the till confirms, the code goes.//// import ui/pay_code.qwe use PayCode//// <PayCode amount="12.50" payee="Cafe Luz" seed={wallet_seed} period={30} confirmed={paid}/>// <PayCode amount="12.50" payee="Cafe Luz" on_refresh={(step) => next_code(step)} confirmed={paid}/>//// amount: a decimal or text. currency ("USD"), locale. payee: who it is for. period: seconds// a code lasts (30). seed: what codes are worked out from, here, with no server; such a// code only stands for one given by your payment service, so a real one comes from// on_refresh(step): it gives the code for that period, and may wait for a server fn; an// error value or null says so and tries again next second. scheme: what starts the QR's// text ("pay"). confirmed: true when the payment has gone through: from your own state,// which your till or server sets. hold_ms: how long to hold (800). lock_after: seconds// until it locks by itself (120). on_show(), on_lock(): as it opens and locks.//// Brightness needs the phone apps' quilloNative.brightness: the page says how it went, and a// plain browser, which cannot change it, asks the person, and keeps the screen awake by// Wake Lock where there is one. A refusal is said the same way.//// pay_code_value(seed, step) and pay_code_step(ms, period) are the working.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/price.qwe use price_textimport ui/qr_code.qwe use QrCode, qr_code_byteslet pay_code_css = css`.ui-paycode { display: grid; gap: 12px; justify-items: center; min-width: 0; container-type: inline-size; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); text-align: center; }.ui-paycode-sum { margin: 0; color: var(--ui-muted); }.ui-paycode-sum strong { display: block; color: var(--ui-fg); font-size: 28px; font-variant-numeric: tabular-nums; }.ui-paycode-cols { display: grid; gap: 12px; justify-items: center; width: 100%; min-width: 0; }/* A hold is the finger's: no scroll, selection or callout may take it. */.ui-paycode-hold { position: relative; display: grid; place-items: center; width: 150px; height: 150px; padding: 0; overflow: hidden; border: 2px solid var(--ui-accent); border-radius: 50%; background: var(--ui-accent-soft); color: var(--ui-fg); font: 600 15px/1.3 var(--ui-font); cursor: pointer; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;}.ui-paycode-hold::before { content: ""; position: absolute; inset: 0; background: var(--ui-accent); opacity: .35; transform: scaleY(0); transform-origin: 50% 100%; }.ui-paycode-holding::before { transform: scaleY(1); transition: transform var(--ui-paycode-hold, .8s) linear; }.ui-paycode-armed::before { transform: scaleY(1); }.ui-paycode-hold span { position: relative; }.ui-paycode-hold:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-paycode-ask { display: grid; gap: 8px; justify-items: center; margin: 0; }.ui-paycode-ask p { margin: 0; }.ui-paycode-acts { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }.ui-paycode-qr { box-sizing: border-box; width: min(100%, 300px); padding: 8px; border-radius: var(--ui-radius); background: #fff; color: #000; }.ui-paycode-qr figure { margin: 0; }.ui-paycode-qr svg { display: block; width: 100%; height: auto; }.ui-paycode-digits { margin: 0; font: 600 24px/1.3 var(--ui-mono); letter-spacing: .04em; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }.ui-paycode-clock { width: min(100%, 300px); }.ui-paycode-clock p { margin: 0 0 4px; color: var(--ui-muted); font-variant-numeric: tabular-nums; }.ui-paycode-left { height: 6px; overflow: hidden; border-radius: 999px; background: var(--ui-subtle); }.ui-paycode-left span { display: block; height: 100%; background: var(--ui-accent); transition: width .9s linear; }.ui-paycode-note { margin: 0; color: var(--ui-muted); font-size: 13px; max-width: 34em; }.ui-paycode-bad { color: var(--ui-danger); }.ui-paycode-paid { margin: 0; color: var(--ui-success); font-size: 22px; font-weight: 700; }@container (min-width: 560px) { .ui-paycode-showing { grid-template-columns: minmax(0, 300px) minmax(0, 1fr); align-items: center; gap: 28px; }}@media (prefers-reduced-motion: reduce) { .ui-paycode-holding::before, .ui-paycode-left span { transition: none; } .ui-paycode-holding::before { opacity: .6; } }`// Which period a moment falls in.fn pay_code_step(ms, period: int) -> int = int(floor(ms / 1000 / max([1, period])))// Seconds left in that period.fn pay_code_left(ms, period: int) -> int { let p = max([1, period]) p - int(floor(ms / 1000)) % p}// A 31-bit mix of bytes: the same on server and browser, as every step stays under 2^53.fn pay_code_mix(bytes: list, start: int) -> int = bytes | reduce(start, (h, b) => (h * 131 + b + 7) % 2147483647)// The Luhn digit that makes the digits before it check.fn pay_code_luhn(digits: str) -> str { let sum_all = reverse(split(digits, "")) | enumerate() | reduce(0, (a, p) => { let d = int(p[1]) let x = p[0] % 2 == 0 ? d * 2 : d a + (x > 9 ? x - 9 : x) }) str((10 - sum_all % 10) % 10)}// A code of twelve digits for a seed and a period, its last digit a check. It stands for// one from a payment service: anyone holding the seed can make the same.fn pay_code_value(seed, step: int) -> str { let bytes = qr_code_bytes(`${str(seed ?? "")}:${step}`) let a = pay_code_mix(bytes, 7) let b = pay_code_mix(reverse(bytes), a % 65521 + 1) let c = `${a % 1000000}` let d = `${b % 100000}` let body = repeat("0", 6 - len(c)) + c + repeat("0", 5 - len(d)) + d body + pay_code_luhn(body)}// The amount in its currency: a decimal or a number is written out, other text as it is.fn pay_code_amount(amount, currency: str, locale: str?) -> str { if amount == null { return "" } let d = dec(amount) price_text(is_err(d) ? str(amount) : d, currency, locale)}// Digits in fours, to read aloud.fn pay_code_groups(code: str) -> str { let n = len(code) range(0, ceil(n / 4)) | map((i) => slice(code, i * 4, min([(i + 1) * 4, n]))) | join(" ")}component PayCode(amount = null, currency: str = "USD", locale: str? = null, payee: str = "", period: int = 30, seed: str = "", scheme: str = "pay", confirmed: bool = false, hold_ms: int = 800, lock_after: int = 120, on_refresh = null, on_show = null, on_lock = null) { style global pay_code_css state phase = confirmed ? "paid" : "locked" // locked, shown or paid state holding = false state armed = false // held long enough: it opens when the finger lifts, as what opens now under a held finger would take its release state grab = null // where a hold began state asked = false // the way without holding is open state code = "" state step = -1 state left = period state until = 0 // when it locks by itself, in ms state as_digits = false state failed = false state bright = "" // "app", "refused" or "web": what could be done about brightness state saved = null // the brightness to go back to state wake = null // the browser's Wake Lock state waiting = false // on_refresh has not answered yet let sum_text = pay_code_amount(amount, currency, locale) let got = (r, s) => { waiting = false if r == null || is_err(r) { failed = true } else { failed = false code = str(r) step = s } } let renew = (s) => { if on_refresh == null { got(pay_code_value(seed, s), s) } else if !waiting { waiting = true let r = on_refresh(s) if type_of(r) == "promise" { r.then((v = null) => { got(v, s) }, (_e = null) => { got(null, s) }) } else { got(r, s) } } } let brighten = () => { let w = js("window") if w.quilloNative != null { let was = js("quilloNative.brightness") let set_it = is_err(was) ? was : js("quilloNative.brightness", 1) if is_err(was) || is_err(set_it) { bright = "refused" } else { saved = was bright = "app" } js("quilloNative.keepAwake", true) } else { bright = "web" let locks = js("navigator").wakeLock if locks != null { locks.request("screen").then((s) => { wake = s }, (_e = null) => { wake = null }) } } } let dim = () => { let w = js("window") if w.quilloNative != null { if saved != null { js("quilloNative.brightness", saved) } js("quilloNative.keepAwake", false) } saved = null bright = "" if wake != null { wake.release() wake = null } } let show = () => { if phase == "locked" { asked = false phase = "shown" let t = now() until = t + lock_after * 1000 left = pay_code_left(t, period) renew(pay_code_step(t, period)) brighten() on_show && on_show() } } let lock = () => { if phase == "shown" { phase = "locked" code = "" step = -1 failed = false dim() on_lock && on_lock() } } if holding { every(hold_ms, () => { holding = false armed = true }) } // The clock is read each second, not counted: `every` stands still while the phone sleeps. if phase == "shown" { every(1000, () => { let t = now() if t >= until { lock() } else { left = pay_code_left(t, period) let s = pay_code_step(t, period) if s != step || failed { renew(s) } } }) } if confirmed && phase != "paid" { if phase == "shown" { dim() } phase = "paid" code = "" holding = false armed = false } let let_go = (_e = null) => { holding = false armed = false grab = null } <div.ui-paycode @mount={(_el) => { let d = js("document") let away = (_e = null) => { if d.visibilityState == "hidden" { lock() } } d.addEventListener("visibilitychange", away) js("window").addEventListener("pagehide", away) () => { d.removeEventListener("visibilitychange", away) js("window").removeEventListener("pagehide", away) lock() } }}> <p.ui-paycode-sum>{payee != "" ? `Pay ${payee}` : "Pay"}<strong>{sum_text}</strong></p> {phase == "locked" && <div.ui-paycode-cols> <button type="button" class={ui_cls(["ui-paycode-hold", holding && "ui-paycode-holding", armed && "ui-paycode-armed"])} style={`--ui-paycode-hold: ${hold_ms}ms`} aria-label={`Show the code to pay ${sum_text}`} @pointerdown={(e) => { if e.button == 0 { e.target.closest(".ui-paycode-hold").setPointerCapture(e.pointerId) grab = { x: e.clientX, y: e.clientY } holding = true } }} @pointermove={(e) => { if grab != null && (abs(e.clientX - grab.x) > 16 || abs(e.clientY - grab.y) > 16) { let_go() } }} @pointerup={(_e) => { let opens = armed let_go() if opens { show() } }} @pointercancel={let_go} @contextmenu={(e) => { e.preventDefault() }} @click={(e) => { if e.detail == 0 { show() } }}> <span>{armed ? "Let go to show" : holding ? "Keep holding" : "Hold to show the code"}</span> </button> {asked ? <div.ui-paycode-ask> <p>Show the code to pay {sum_text}?</p> <div.ui-paycode-acts> <Button size="lg" on_click={show}>Yes, show it</Button> <Button variant="ghost" size="lg" on_click={() => { asked = false }}>Not now</Button> </div> </div> : <Button variant="ghost" size="lg" on_click={() => { asked = true }}>Show without holding</Button>} </div>} {phase == "shown" && <div class="ui-paycode-cols ui-paycode-showing"> {as_digits ? <p.ui-paycode-digits aria-label={`Code: ${split(code, "") | join(" ")}`}>{pay_code_groups(code)}</p> : <div.ui-paycode-qr>{code != "" && <QrCode text={`${scheme}:${code}`} size={280} label={`QR code to pay ${sum_text}`}/>}</div>} <div.ui-paycode-cols> <div.ui-paycode-clock> <p role="timer">New code in {left} s</p> <div.ui-paycode-left aria-hidden="true"><span style={`width: ${round(left * 100 / max([1, period]))}%`}></span></div> </div> {failed && <p class="ui-paycode-note ui-paycode-bad" role="alert">A new code could not be fetched. Trying again.</p>} {bright == "app" && <p.ui-paycode-note>Screen brightness is up until you finish.</p>} {bright == "refused" && <p.ui-paycode-note>Brightness could not be changed: turn it up yourself so the reader can see the code.</p>} {bright == "web" && <p.ui-paycode-note>This browser cannot change brightness: turn it up yourself so the reader can see the code.</p>} <div.ui-paycode-acts> <Button variant="outline" size="lg" on_click={() => { as_digits = !as_digits }}>{as_digits ? "Show QR code" : "Show digits"}</Button> <Button variant="ghost" size="lg" on_click={lock}>Lock</Button> </div> </div> </div>} {phase == "paid" && <p.ui-paycode-paid role="status">Paid</p>} </div>}Related
CheckoutSheetA 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 downloadsBoardingPassCardA boarding pass with its code ready at full brightness, the gate and time large, a countdown, and offline working.3 views · 8 downloadsCartSheetA 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 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 downloadsPosRegisterA tablet till: a product grid, ticket, quantity and discount, cash and card, in decimals, keeping the sale through a reload.2 views · 9 downloads
Thread
Thread 0 posts
On PayCode: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.