Finance

CardControls

A card's controls beside its masked face: freeze it, set daily spending and cash limits, and allow online, ATM, contactless and abroad use.

#finance#banking#forms#settings#card#interactive

Demo

Preview
Daily limits
$1,500
$300
Where it works

Allowed: online, ATM, contactless. Blocked: abroad.

Change something.

Daily limits
2.000 €
0 €
Where it works

Frozen: no payments or withdrawals go through until you unfreeze the card.

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/card_controls.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/card_controls.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/card_controls.qwe
component CardControlsDemoLive() {    state said = ""    <div.demo-stack>        <CardControls id="card-controls-demo" brand="Visa" kind="Debit" holder="Dana Whitfield" number="4000 1234 5678 4821" expiry="08/29"                      spend_limit={1500} cash_limit={300} abroad={false}                      on_change={(s) => { said = `frozen ${s.frozen}, online ${s.online}, atm ${s.atm}, abroad ${s.abroad}, spend ${s.spend_limit}, cash ${s.cash_limit}` }}/>        <p.demo-note>{said == "" ? "Change something." : `on_change: ${said}`}</p>    </div>}view CardControlsDemo() =    <div.demo-stack>        <CardControlsDemoLive/>        <CardControls id="card-controls-demo-credit" brand="Mastercard" kind="Credit" holder="Dana Whitfield" number="1937" expiry="02/28"                      face="dark" frozen={true} currency="EUR" locale="de-DE" spend_limit={2000} spend_max={8000} cash_limit={0} cash_max={500}/>    </div>

Source

ui/card_controls.qwe
// CardControls: a debit or credit card's controls beside the card itself (its// number masked): a switch to freeze it, daily limits for spending and for cash,// and switches for online, ATM, contactless and abroad use. A line under them// says what is allowed and what is blocked. Every control is a real input with a// name (frozen, online, atm, contactless, abroad, spend_limit, cash_limit), so a// plain <form> around it sends them.////   <CardControls brand="Visa" kind="Debit" holder="Dana Whitfield" number="4821" expiry="08/29"//                 spend_limit={1500} spend_max={5000} cash_limit={300} cash_max={1000} abroad={false}/>//   <CardControls number="1937" kind="Credit" frozen={true} on_change={(s) => { settings = s }}/>//// brand ("Visa", "Mastercard", ...), kind ("Debit" or "Credit"), holder, number// (only its last four show), expiry ("MM/YY"): what the card face shows. face:// "accent" (default), "dark" or "green". frozen: whether it starts frozen; a// frozen card shows so, and the line says nothing goes through. online, atm,// contactless, abroad: each use allowed (true) or not. spend_limit and cash_limit:// the daily limits to start with; spend_max (5000) and cash_max (1000) the most// each can be set to, step (50) what each move of a slider adds. currency ("USD")// and locale: how the limits are written. on_change(settings): inside one of your// components, given { frozen, online, atm, contactless, abroad, spend_limit,// cash_limit } after each change. id: the element's id, and the start of its fields'.import ui/theme.qwe use ui_clslet card_controls_css = css`.ui-cardctl { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 20px 28px; align-items: start; min-width: 0;  font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }@media (max-width: 640px) { .ui-cardctl { grid-template-columns: minmax(0, 1fr); } }.ui-cardctl-face { --f: var(--ui-accent); position: relative; display: grid; grid-template-rows: auto 1fr auto auto; gap: 8px; aspect-ratio: 1.586;  max-width: 300px; padding: 18px 20px; border-radius: 14px; overflow: hidden; color: #fff;  background: linear-gradient(135deg, color-mix(in srgb, var(--f) 92%, #fff) 0%, var(--f) 45%, color-mix(in srgb, var(--f) 60%, #000) 100%);  box-shadow: var(--ui-shadow); transition: filter .2s; }.ui-cardctl-dark .ui-cardctl-face { --f: #23262e; }.ui-cardctl-green .ui-cardctl-face { --f: #0f7a55; }.ui-cardctl-face::after { content: ""; position: absolute; right: -40px; top: -60px; width: 190px; height: 190px; border-radius: 50%; background: rgba(255, 255, 255, .08); }.ui-cardctl-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }.ui-cardctl-brand { font-size: 16px; font-style: italic; font-weight: 800; letter-spacing: .02em; text-transform: none; }.ui-cardctl-chip { width: 36px; height: 27px; align-self: center; }.ui-cardctl-pan { font: 500 16px var(--ui-mono); letter-spacing: .12em; white-space: nowrap; }.ui-cardctl-bottom { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }.ui-cardctl-bottom small { display: block; font-size: 9px; opacity: .75; }.ui-cardctl-ice { position: absolute; inset: 0; z-index: 1; display: none; place-content: center; justify-items: center; gap: 4px;  background: rgba(200, 222, 255, .35); backdrop-filter: blur(1.5px); color: #fff; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0, 0, 0, .35); }.ui-cardctl-ice svg { width: 30px; height: 30px; }.ui-cardctl-frozen .ui-cardctl-face { filter: grayscale(.85); }.ui-cardctl-frozen .ui-cardctl-ice { display: grid; }.ui-cardctl-panel { display: grid; gap: 14px; min-width: 0; }.ui-cardctl-group { display: grid; margin: 0; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-cardctl-legend { float: left; width: 100%; padding: 10px 14px 0; color: var(--ui-muted); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }.ui-cardctl-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 14px; cursor: pointer; }.ui-cardctl-row + .ui-cardctl-row { border-top: 1px solid var(--ui-border); }.ui-cardctl-text { display: grid; gap: 1px; }.ui-cardctl-name { font-weight: 500; }.ui-cardctl-hint { color: var(--ui-muted); font-size: 12px; }.ui-cardctl-row input[type=checkbox] { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }.ui-cardctl-track { flex: none; position: relative; width: 38px; height: 22px; border-radius: 999px; background: var(--ui-strong); transition: background .15s; }.ui-cardctl-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff;  box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s; }.ui-cardctl-row input:checked + .ui-cardctl-track { background: var(--ui-accent); }.ui-cardctl-row input:checked + .ui-cardctl-track::after { transform: translateX(16px); }.ui-cardctl-row input:focus-visible + .ui-cardctl-track { box-shadow: var(--ui-focus); }.ui-cardctl-freeze input:checked + .ui-cardctl-track { background: var(--ui-info); }.ui-cardctl-limit { display: grid; gap: 6px; padding: 11px 14px; }.ui-cardctl-limit + .ui-cardctl-limit { border-top: 1px solid var(--ui-border); }.ui-cardctl-limit-head { display: flex; justify-content: space-between; gap: 10px; }.ui-cardctl-limit output { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-cardctl-limit input[type=range] { width: 100%; margin: 0; accent-color: var(--ui-accent); }.ui-cardctl-scale { display: flex; justify-content: space-between; color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; }.ui-cardctl-uses { transition: opacity .15s; }.ui-cardctl-frozen .ui-cardctl-uses { opacity: .6; }.ui-cardctl-summary { margin: 0; padding: 9px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-muted); font-size: 13px; }.ui-cardctl-frozen .ui-cardctl-summary { background: var(--ui-info-soft); color: var(--ui-info); font-weight: 500; }@media (prefers-reduced-motion: reduce) { .ui-cardctl-face, .ui-cardctl-track, .ui-cardctl-track::after, .ui-cardctl-uses { transition: none; } }`// A limit as text in its currency, without cents when it is whole ("$1,500").fn card_controls_money(amount, currency: str, locale: str?) -> str {    let m = money.of(dec(amount), currency, "half_up")    if is_err(m) { return str(amount) }    let text = locale != null ? money.format(m, locale) : money.format(m)    let units = money.minor_units(currency)    units == 0 || m.amount != floor(m.amount) ? text : re.replace(text, `[.,]0{${units}}(\\D*)$`, "$1")}// What the card may do, in a sentence: "Allowed: online, contactless. Blocked: ATM, abroad."fn card_controls_summary(frozen: bool, uses: list) -> str {    if frozen { return "Frozen: no payments or withdrawals go through until you unfreeze the card." }    let on = uses | filter((u) => u.on) | map((u) => u.word)    let off = uses | filter((u) => !u.on) | map((u) => u.word)    let a = len(on) > 0 ? `Allowed: ${join(on, ", ")}.` : "Every use is blocked."    len(off) > 0 && len(on) > 0 ? `${a} Blocked: ${join(off, ", ")}.` : a}component CardControls(number: str = "", brand: str = "Visa", kind: str = "Debit", holder: str? = null, expiry: str? = null,                       face: str = "accent", frozen: bool = false, online: bool = true, atm: bool = true,                       contactless: bool = true, abroad: bool = true, spend_limit: int = 1500, cash_limit: int = 300,                       spend_max: int = 5000, cash_max: int = 1000, step: int = 50, currency: str = "USD",                       locale: str? = null, on_change = null, id: str? = null, class: str? = null) {    style global card_controls_css    state is_frozen = frozen    state use_online = online    state use_atm = atm    state use_contactless = contactless    state use_abroad = abroad    state spend = spend_limit    state cash = cash_limit    let code = upper(currency)    let base = id ?? "card-controls"    let last4 = slice(re.replace(number, "[^0-9A-Za-z]", ""), -4)    let uses = [        { key: "online", label: "Online and phone", hint: "Shops on the web, in apps and by phone", on: use_online, word: "online" },        { key: "atm", label: "Cash machines", hint: "Withdrawals at ATMs", on: use_atm, word: "ATM" },        { key: "contactless", label: "Contactless", hint: "Tap to pay, with the card or a phone", on: use_contactless, word: "contactless" },        { key: "abroad", label: "Abroad", hint: "Payments and cash outside the country", on: use_abroad, word: "abroad" },    ]    let report = () => {        on_change && on_change({ frozen: is_frozen, online: use_online, atm: use_atm, contactless: use_contactless,                                 abroad: use_abroad, spend_limit: spend, cash_limit: cash })    }    let flip = (key: str, on: bool) => {        match key {            "online" => { use_online = on }            "atm" => { use_atm = on }            "contactless" => { use_contactless = on }            _ => { use_abroad = on }        }        report()    }    let said = `${brand} ${lower(kind)} card ending in ${last4}${is_frozen ? ", frozen" : ""}`    <div class={ui_cls(["ui-cardctl", face != "accent" && `ui-cardctl-${face}`, is_frozen && "ui-cardctl-frozen", class])} id={id}>        <div.ui-cardctl-face role="img" aria-label={said}>            <div.ui-cardctl-top><span>{kind}</span><span.ui-cardctl-brand>{brand}</span></div>            <svg.ui-cardctl-chip viewBox="0 0 36 27" aria-hidden="true"><rect x=".5" y=".5" width="35" height="26" rx="5" fill="#e6c76a" stroke="#b8963c"/><path d="M12 1v25M24 1v25M1 9h11M24 9h11M1 18h11M24 18h11" stroke="#b8963c" fill="none"/></svg>            <div.ui-cardctl-pan aria-hidden="true">•••• •••• •••• {last4}</div>            <div.ui-cardctl-bottom aria-hidden="true">                <span><small>Card holder</small>{holder ?? ""}</span>                {expiry != null && <span><small>Expires</small>{expiry}</span>}            </div>            <div.ui-cardctl-ice aria-hidden="true">                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9 4l3 2 3-2M9 20l3-2 3 2M4 10.5l1-3.5-3.5-1M20 13.5l-1 3.5 3.5 1M4 13.5l1 3.5-3.5 1M20 10.5l-1-3.5 3.5-1"/></svg>                Frozen            </div>        </div>        <div.ui-cardctl-panel>            <div.ui-cardctl-group>                <label.ui-cardctl-row.ui-cardctl-freeze>                    <span.ui-cardctl-text>                        <span.ui-cardctl-name>Freeze card</span>                        <span.ui-cardctl-hint>Stops every payment and withdrawal until you unfreeze it</span>                    </span>                    <input type="checkbox" role="switch" id={`${base}-frozen`} name="frozen" checked={is_frozen}                           @change={(e) => { is_frozen = e.checked; report() }}/>                    <span.ui-cardctl-track aria-hidden="true"></span>                </label>            </div>            <fieldset.ui-cardctl-group>                <legend.ui-cardctl-legend>Daily limits</legend>                <div.ui-cardctl-limit>                    <div.ui-cardctl-limit-head>                        <label for={`${base}-spend`}>Spending</label>                        <output for={`${base}-spend`} id={`${base}-spend-out`}>{card_controls_money(spend, code, locale)}</output>                    </div>                    <input type="range" id={`${base}-spend`} name="spend_limit" min="0" max={spend_max} step={step} value={spend}                           aria-valuetext={`${card_controls_money(spend, code, locale)} a day`}                           @input={(e) => { spend = int(e.value) ?? spend }} @change={() => report()}/>                    <div.ui-cardctl-scale aria-hidden="true"><span>{card_controls_money(0, code, locale)}</span><span>{card_controls_money(spend_max, code, locale)}</span></div>                </div>                <div.ui-cardctl-limit>                    <div.ui-cardctl-limit-head>                        <label for={`${base}-cash`}>Cash withdrawals</label>                        <output for={`${base}-cash`} id={`${base}-cash-out`}>{card_controls_money(cash, code, locale)}</output>                    </div>                    <input type="range" id={`${base}-cash`} name="cash_limit" min="0" max={cash_max} step={step} value={cash}                           aria-valuetext={`${card_controls_money(cash, code, locale)} a day`}                           @input={(e) => { cash = int(e.value) ?? cash }} @change={() => report()}/>                    <div.ui-cardctl-scale aria-hidden="true"><span>{card_controls_money(0, code, locale)}</span><span>{card_controls_money(cash_max, code, locale)}</span></div>                </div>            </fieldset>            <fieldset.ui-cardctl-group.ui-cardctl-uses>                <legend.ui-cardctl-legend>Where it works</legend>                {for u in uses => <label.ui-cardctl-row key={u.key}>                    <span.ui-cardctl-text>                        <span.ui-cardctl-name>{u.label}</span>                        <span.ui-cardctl-hint>{u.hint}</span>                    </span>                    <input type="checkbox" role="switch" id={`${base}-${u.key}`} name={u.key} checked={u.on}                           @change={(e) => flip(u.key, e.checked)}/>                    <span.ui-cardctl-track aria-hidden="true"></span>                </label>}            </fieldset>            <p.ui-cardctl-summary role="status">{card_controls_summary(is_frozen, uses)}</p>        </div>    </div>}
Thread
76Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.