Mobile

PayCode

A QR or code to pay with, refreshing on a timer, screen brightness up while shown, locked until confirmed.

#mobile#interactive#commerce#payment#qr

Demo

Preview

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.qwe
main.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>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

On PayCode: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.